From b97478d13d00572eca939f5466c29a02cc82b8ca Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Sun, 7 Jun 2026 21:54:17 -0500 Subject: [PATCH 1/2] feat(vite): augment library .d.ts with Ivy type declarations (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library builds (`compilationMode: 'partial'`) now emit Angular's Ivy `.d.ts` type declarations (`static ɵfac`, `static ɵcmp`, …) so consumers get full template type-checking against a published library. The compiler already returns these per-class members on the transform result (`dtsDeclarations`); this wires them through the Vite plugin. A new post-enforce `dtsPlugin` collects declarations during `transform` and, in `generateBundle`, splices them into the `.d.ts` assets emitted by a separate declaration generator (rolldown-plugin-dts, vite-plugin-dts, tsdown, tsc), adding the required `import * as i0 from "@angular/core"`. The plugin does not generate the base `.d.ts` itself — it augments existing ones. No-op outside partial mode. Co-Authored-By: Claude Opus 4.8 (1M context) --- napi/angular-compiler/README.md | 30 +++- napi/angular-compiler/test/dts.test.ts | 144 ++++++++++++++++++ napi/angular-compiler/vite-plugin/index.ts | 60 ++++++++ .../angular-compiler/vite-plugin/utils/dts.ts | 123 +++++++++++++++ 4 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 napi/angular-compiler/test/dts.test.ts create mode 100644 napi/angular-compiler/vite-plugin/utils/dts.ts 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..47e43ff54 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,13 @@ 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 class name. 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 + // class name (last write wins) since a library publishes one class per name. + const collectedDtsDeclarations = new Map() + function getMinifyComponentStyles(context?: { environment?: { config?: { build?: ResolvedConfig['build'] } } }): boolean { @@ -672,6 +680,14 @@ 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') { + for (const decl of result.dtsDeclarations) { + collectedDtsDeclarations.set(decl.className, 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 +949,49 @@ 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 + + const declarations = Array.from(collectedDtsDeclarations, ([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 +1024,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 +} From 33235bd4e731691bd1cf583c6a66629a0a9554a0 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Mon, 8 Jun 2026 08:13:49 -0500 Subject: [PATCH 2/2] fix(vite): evict stale library .d.ts declarations on watch rebuilds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key the collected Ivy `.d.ts` declarations by module id instead of class name so `vite build --watch` can drop a module's prior entries before re-transforming it. Eviction runs ahead of the quick decorator early-return, so removing a decorator no longer leaves stale `ɵfac`/`ɵcmp` metadata to be re-injected into a now-plain class. generateBundle flattens module entries into a class-name map (last write wins), matching prior behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- napi/angular-compiler/vite-plugin/index.ts | 44 +++++++++++++++++----- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 47e43ff54..758aba31b 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -263,11 +263,16 @@ export function angular(options: PluginOptions = {}): Plugin[] { const componentMetadataCache = new Map() // Angular Ivy `.d.ts` static member declarations collected across the build, - // keyed by class name. Populated during `transform` in `compilationMode: + // 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 - // class name (last write wins) since a library publishes one class per name. - const collectedDtsDeclarations = new Map() + // 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'] } } @@ -609,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 = @@ -682,10 +695,14 @@ export function angular(options: PluginOptions = {}): Plugin[] { // 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') { - for (const decl of result.dtsDeclarations) { - collectedDtsDeclarations.set(decl.className, decl.members) - } + 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. @@ -969,7 +986,16 @@ export function angular(options: PluginOptions = {}): Plugin[] { if (pluginOptions.compilationMode !== 'partial') return if (collectedDtsDeclarations.size === 0) return - const declarations = Array.from(collectedDtsDeclarations, ([className, members]) => ({ + // 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, }))