diff --git a/napi/angular-compiler/README.md b/napi/angular-compiler/README.md
index 65f9b6e66..873a491e8 100644
--- a/napi/angular-compiler/README.md
+++ b/napi/angular-compiler/README.md
@@ -218,13 +218,41 @@ interface AngularPluginOptions {
For `"auto"`, the plugin uses `build.cssMinify` when it is set, otherwise it falls back to `build.minify`. In dev, `"auto"` defaults to `false`.
+### Library builds (`.d.ts`)
+
+For publishing an Angular library (the ng-packagr-style workflow, e.g. with
+Rolldown/tsdown), set `compilationMode: 'partial'`. This emits partial
+declarations (`ɵɵngDeclareComponent`, …) in the JavaScript output, and the
+plugin also augments the emitted `.d.ts` with Angular's Ivy type declarations
+(`static ɵfac`, `static ɵcmp`, …) so downstream consumers get full template
+type-checking against your library.
+
+```typescript
+// vite.config.ts — Angular library build
+import { angular } from '@oxc-angular/vite'
+import dts from 'rolldown-plugin-dts' // or vite-plugin-dts / tsdown
+
+export default defineConfig({
+ plugins: [angular({ compilationMode: 'partial' }), dts()],
+ build: { lib: { entry: 'src/public-api.ts', formats: ['es'] } },
+})
+```
+
+The plugin does **not** generate the base `.d.ts` itself — a declaration
+generator (`rolldown-plugin-dts`, `vite-plugin-dts`, `tsdown`, or `tsc`) must
+produce them. The Angular members are then spliced into those files during
+`generateBundle`. The injected members reference `i0` (the `@angular/core`
+namespace), and the plugin adds `import * as i0 from "@angular/core";` to any
+`.d.ts` it augments.
+
## Vite Plugin Architecture
-The Vite plugin consists of three sub-plugins:
+The Vite plugin consists of these sub-plugins:
1. **Transform Plugin** - Transforms Angular TypeScript files
2. **HMR Plugin** - Handles hot module replacement for templates and styles
3. **Styles Plugin** - Processes and encapsulates component styles
+4. **Dts Plugin** - Augments library `.d.ts` with Ivy type declarations (partial mode)
### HMR Routes
diff --git a/napi/angular-compiler/test/dts.test.ts b/napi/angular-compiler/test/dts.test.ts
new file mode 100644
index 000000000..dcc354dce
--- /dev/null
+++ b/napi/angular-compiler/test/dts.test.ts
@@ -0,0 +1,144 @@
+import type { Plugin } from 'vite'
+import { describe, expect, it } from 'vitest'
+
+import { angular } from '../vite-plugin/index.js'
+import { injectDtsDeclarations } from '../vite-plugin/utils/dts.js'
+
+const COMPONENT_SOURCE = `
+ import { Component } from '@angular/core';
+
+ @Component({
+ selector: 'app-lib-button',
+ template: '',
+ standalone: true,
+ })
+ export class LibButtonComponent {}
+`
+
+describe('injectDtsDeclarations', () => {
+ it('splices members into the matching class and adds the i0 import', () => {
+ const source = `export declare class LibButtonComponent {\n}\n`
+ const out = injectDtsDeclarations(source, [
+ {
+ className: 'LibButtonComponent',
+ members:
+ 'static ɵfac: i0.ɵɵFactoryDeclaration;\n' +
+ 'static ɵcmp: i0.ɵɵComponentDeclaration;',
+ },
+ ])
+
+ expect(out).toContain('import * as i0 from "@angular/core";')
+ expect(out).toContain('static ɵfac: i0.ɵɵFactoryDeclaration;')
+ expect(out).toContain(
+ 'static ɵcmp: i0.ɵɵComponentDeclaration;',
+ )
+ // Members land inside the class body, before its closing brace.
+ const facIdx = out.indexOf('ɵfac')
+ const braceIdx = out.indexOf('class LibButtonComponent')
+ const closeIdx = out.lastIndexOf('}')
+ expect(braceIdx).toBeLessThan(facIdx)
+ expect(facIdx).toBeLessThan(closeIdx)
+ })
+
+ it('is idempotent — re-running does not duplicate members', () => {
+ const source = `export declare class Foo {\n}\n`
+ const decls = [
+ { className: 'Foo', members: 'static ɵfac: i0.ɵɵFactoryDeclaration;' },
+ ]
+ const once = injectDtsDeclarations(source, decls)
+ const twice = injectDtsDeclarations(once, decls)
+ expect(twice).toBe(once)
+ expect(once.match(/ɵfac/g)).toHaveLength(1)
+ })
+
+ it('reuses an existing i0 import instead of adding a second one', () => {
+ const source = 'import * as i0 from "@angular/core";\nexport declare class Foo {\n}\n'
+ const out = injectDtsDeclarations(source, [
+ { className: 'Foo', members: 'static ɵfac: i0.ɵɵFactoryDeclaration;' },
+ ])
+ expect(out.match(/@angular\/core/g)).toHaveLength(1)
+ })
+
+ it('keeps the i0 import after leading triple-slash references', () => {
+ const source = '/// \nexport declare class Foo {\n}\n'
+ const out = injectDtsDeclarations(source, [
+ { className: 'Foo', members: 'static ɵfac: i0.ɵɵFactoryDeclaration;' },
+ ])
+ expect(out.indexOf('/// {
+ const source = `export declare class Other {\n}\n`
+ const out = injectDtsDeclarations(source, [
+ { className: 'Missing', members: 'static ɵfac: i0.ɵɵFactoryDeclaration;' },
+ ])
+ expect(out).toBe(source)
+ })
+})
+
+describe('angular() dts plugin (#104)', () => {
+ function getPlugins(compilationMode: 'full' | 'partial') {
+ const plugins = angular({ compilationMode })
+ const transform = plugins.find((p) => p.name === '@oxc-angular/vite')
+ const dts = plugins.find((p) => p.name === '@oxc-angular/vite-dts')
+ if (!transform || !dts) throw new Error('missing plugins')
+ return { transform, dts }
+ }
+
+ async function runTransform(transform: Plugin) {
+ if (!transform.transform || typeof transform.transform === 'function') {
+ throw new Error('expected transform handler')
+ }
+ await transform.transform.handler.call(
+ {
+ error(message: string) {
+ throw new Error(message)
+ },
+ warn() {},
+ } as any,
+ COMPONENT_SOURCE,
+ 'lib-button.component.ts',
+ )
+ }
+
+ function makeBundle(dtsSource: string) {
+ return {
+ 'index.d.ts': {
+ type: 'asset' as const,
+ fileName: 'index.d.ts',
+ source: dtsSource,
+ },
+ }
+ }
+
+ async function runGenerateBundle(dts: Plugin, bundle: unknown) {
+ const hook = dts.generateBundle
+ if (!hook) throw new Error('expected generateBundle')
+ const fn = typeof hook === 'function' ? hook : hook.handler
+ await fn.call({} as any, {} as any, bundle as any, false)
+ }
+
+ it('augments .d.ts assets in partial mode', async () => {
+ const { transform, dts } = getPlugins('partial')
+ await runTransform(transform)
+
+ const bundle = makeBundle('export declare class LibButtonComponent {\n}\n')
+ await runGenerateBundle(dts, bundle)
+
+ const out = bundle['index.d.ts'].source as string
+ expect(out).toContain('import * as i0 from "@angular/core";')
+ expect(out).toContain('static ɵfac: i0.ɵɵFactoryDeclaration {
+ const { transform, dts } = getPlugins('full')
+ await runTransform(transform)
+
+ const original = 'export declare class LibButtonComponent {\n}\n'
+ const bundle = makeBundle(original)
+ await runGenerateBundle(dts, bundle)
+
+ expect(bundle['index.d.ts'].source).toBe(original)
+ })
+})
diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts
index f983730e3..758aba31b 100644
--- a/napi/angular-compiler/vite-plugin/index.ts
+++ b/napi/angular-compiler/vite-plugin/index.ts
@@ -43,6 +43,7 @@ import {
locateTemplateInArgs,
locateTemplateStringFor,
} from './utils/decorator-fields.js'
+import { injectDtsDeclarations } from './utils/dts.js'
/**
* Plugin options for the Angular Vite plugin.
@@ -261,6 +262,18 @@ export function angular(options: PluginOptions = {}): Plugin[] {
// can dispatch an HMR update instead of a full reload.
const componentMetadataCache = new Map()
+ // Angular Ivy `.d.ts` static member declarations collected across the build,
+ // keyed by module id. Populated during `transform` in `compilationMode:
+ // 'partial'` (library) builds and consumed by `dtsPlugin`'s `generateBundle`
+ // to augment the declaration files a separate dts generator emits.
+ //
+ // Keyed by module (not class name) so `vite build --watch` rebuilds can evict
+ // a module's prior declarations before re-transforming it. Otherwise removing
+ // a decorator — which makes the quick decorator check early-return, skipping
+ // the transform entirely — would leave the old `ɵfac`/`ɵcmp` entries in place
+ // and `generateBundle` would re-inject Ivy metadata into a now-plain class.
+ const collectedDtsDeclarations = new Map>()
+
function getMinifyComponentStyles(context?: {
environment?: { config?: { build?: ResolvedConfig['build'] } }
}): boolean {
@@ -601,6 +614,14 @@ export function angular(options: PluginOptions = {}): Plugin[] {
return
}
+ // Library builds: evict any declarations this module contributed on a
+ // previous (watch) pass before re-deriving them below. Done ahead of
+ // the decorator early-return so a class that just lost its decorator
+ // doesn't keep stale Ivy metadata in the regenerated `.d.ts`.
+ if (pluginOptions.compilationMode === 'partial') {
+ collectedDtsDeclarations.delete(id)
+ }
+
// Quick check for Angular decorators - avoids parsing files without them
// OXC handles @Component, @Directive, @NgModule, @Injectable, and @Pipe
const hasAngularDecorator =
@@ -672,6 +693,18 @@ export function angular(options: PluginOptions = {}): Plugin[] {
this.warn(warning.message)
}
+ // Library builds: stash the Ivy `.d.ts` member declarations for this
+ // file so `dtsPlugin` can splice them into the emitted declarations.
+ if (pluginOptions.compilationMode === 'partial' && result.dtsDeclarations.length > 0) {
+ collectedDtsDeclarations.set(
+ id,
+ result.dtsDeclarations.map((decl) => ({
+ className: decl.className,
+ members: decl.members,
+ })),
+ )
+ }
+
// Track component IDs for HMR — one entry per @Component class.
if (pluginOptions.liveReload) {
// templateUpdates is keyed by `filePath@ClassName` (NAPI HashMap → JS object).
@@ -933,6 +966,58 @@ export function angular(options: PluginOptions = {}): Plugin[] {
/**
* Plugin to encapsulate component styles.
*/
+ /**
+ * Augment library `.d.ts` files with Angular's Ivy type declarations.
+ *
+ * Vite/Rolldown don't emit declarations themselves — a separate dts
+ * generator (rolldown-plugin-dts, vite-plugin-dts, tsdown, `tsc`) produces
+ * the base `.d.ts`. This plugin runs after them (`enforce: 'post'`) and
+ * splices the static `ɵfac`/`ɵcmp`/… members collected during `transform`
+ * into the matching classes so consumers get full template type-checking.
+ *
+ * Only active in `compilationMode: 'partial'` (library) builds; app builds
+ * collect nothing, so this is a no-op there.
+ */
+ function dtsPlugin(): Plugin {
+ return {
+ name: '@oxc-angular/vite-dts',
+ enforce: 'post',
+ generateBundle(_outputOptions, bundle) {
+ if (pluginOptions.compilationMode !== 'partial') return
+ if (collectedDtsDeclarations.size === 0) return
+
+ // Flatten every module's declarations into a class-name-keyed list.
+ // A library publishes one class per name; if names ever collide the
+ // last module wins, matching the previous (class-name-keyed) behavior.
+ const byClassName = new Map()
+ for (const moduleDecls of collectedDtsDeclarations.values()) {
+ for (const decl of moduleDecls) {
+ byClassName.set(decl.className, decl.members)
+ }
+ }
+ const declarations = Array.from(byClassName, ([className, members]) => ({
+ className,
+ members,
+ }))
+
+ for (const file of Object.values(bundle)) {
+ if (file.type !== 'asset') continue
+ if (!file.fileName.endsWith('.d.ts')) continue
+
+ const source =
+ typeof file.source === 'string'
+ ? file.source
+ : Buffer.from(file.source).toString('utf-8')
+
+ const augmented = injectDtsDeclarations(source, declarations)
+ if (augmented !== source) {
+ file.source = augmented
+ }
+ }
+ },
+ }
+ }
+
function stylesPlugin(): Plugin {
return {
name: '@oxc-angular/vite-styles',
@@ -965,6 +1050,7 @@ export function angular(options: PluginOptions = {}): Plugin[] {
return [
angularPlugin(),
stylesPlugin(),
+ dtsPlugin(),
angularLinkerPlugin(),
pluginOptions.jit &&
jitPlugin({
diff --git a/napi/angular-compiler/vite-plugin/utils/dts.ts b/napi/angular-compiler/vite-plugin/utils/dts.ts
new file mode 100644
index 000000000..9b32e529e
--- /dev/null
+++ b/napi/angular-compiler/vite-plugin/utils/dts.ts
@@ -0,0 +1,123 @@
+/**
+ * Inject Angular's Ivy `.d.ts` type declarations into emitted declaration
+ * files for library builds.
+ *
+ * The Rust compiler returns, per Angular class, the static member type
+ * declarations that should live in the class's `.d.ts` body — e.g.
+ * `static ɵcmp: i0.ɵɵComponentDeclaration<…>;`. Those members are what
+ * Angular's template type-checker reads from a pre-compiled library, and
+ * they mirror what ngtsc's `IvyDeclarationDtsTransform` would have written.
+ *
+ * Vite/Rolldown don't emit `.d.ts` themselves — a separate declaration
+ * generator (rolldown-plugin-dts, vite-plugin-dts, tsdown, `tsc`) produces
+ * the base declarations. This helper is a post-processing pass that splices
+ * the Angular members into those already-generated `.d.ts`, and ensures the
+ * `i0` namespace import the members reference is present.
+ *
+ * Known limitation: class bodies are located with a regex that stops at the
+ * first `{` after `class `. A `{` inside a type-parameter constraint or
+ * default (e.g. `class C`) would be mistaken for the body
+ * brace. Emitted library declarations for Angular classes don't use such
+ * generics, so this is accepted in exchange for not pulling in a parser.
+ */
+
+/** A single class's `.d.ts` static member declarations. */
+export interface DtsClassDeclaration {
+ /** The class the members belong to. */
+ className: string
+ /** Newline-separated `static …;` member declarations, `i0`-prefixed. */
+ members: string
+}
+
+const I0_IMPORT = 'import * as i0 from "@angular/core";'
+
+const I0_IMPORT_RE = /import\s+\*\s+as\s+i0\s+from\s+['"]@angular\/core['"]/
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+/**
+ * Insert the `i0` namespace import, keeping it after any leading triple-slash
+ * reference directives and leading comments (which must stay at the top of a
+ * `.d.ts`).
+ */
+function ensureI0Import(source: string): string {
+ if (I0_IMPORT_RE.test(source)) {
+ return source
+ }
+
+ const lines = source.split('\n')
+ let insertAt = 0
+ for (let i = 0; i < lines.length; i++) {
+ const trimmed = lines[i].trim()
+ if (
+ trimmed === '' ||
+ trimmed.startsWith('///') ||
+ trimmed.startsWith('//') ||
+ trimmed.startsWith('/*') ||
+ trimmed.startsWith('*')
+ ) {
+ insertAt = i + 1
+ continue
+ }
+ break
+ }
+
+ lines.splice(insertAt, 0, I0_IMPORT)
+ return lines.join('\n')
+}
+
+/**
+ * Splice each declaration's static members into the matching class body in
+ * `source`, and ensure the `i0` import is present when anything was injected.
+ *
+ * The pass is idempotent: a declaration whose first member already appears in
+ * `source` is skipped, so re-running over an already-augmented file is a
+ * no-op. A declaration whose class isn't found is silently skipped.
+ */
+export function injectDtsDeclarations(
+ source: string,
+ declarations: readonly DtsClassDeclaration[],
+): string {
+ if (declarations.length === 0) {
+ return source
+ }
+
+ let output = source
+ let injected = false
+
+ for (const { className, members } of declarations) {
+ const memberLines = members
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean)
+ if (memberLines.length === 0) {
+ continue
+ }
+
+ // Idempotency: if the members are already present, don't inject again.
+ if (output.includes(memberLines[0])) {
+ continue
+ }
+
+ // Match `(export )?(declare )?(abstract )?class …{`, capturing up
+ // to and including the opening brace of the class body.
+ const classBodyOpen = new RegExp(
+ `(?:export\\s+)?(?:declare\\s+)?(?:abstract\\s+)?class\\s+${escapeRegExp(
+ className,
+ )}\\b[^{]*\\{`,
+ )
+ const match = classBodyOpen.exec(output)
+ if (!match) {
+ continue
+ }
+
+ const insertAt = match.index + match[0].length
+ const body = '\n' + memberLines.map((line) => ` ${line}`).join('\n')
+ output = output.slice(0, insertAt) + body + output.slice(insertAt)
+ injected = true
+ }
+
+ return injected ? ensureI0Import(output) : output
+}