Skip to content

Commit c760d4d

Browse files
feat(vite): augment library .d.ts with Ivy type declarations (#104) (#341)
* feat(vite): augment library .d.ts with Ivy type declarations (#104) 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) <noreply@anthropic.com> * fix(vite): evict stale library .d.ts declarations on watch rebuilds 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 166f6e2 commit c760d4d

4 files changed

Lines changed: 382 additions & 1 deletion

File tree

napi/angular-compiler/README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,13 +218,41 @@ interface AngularPluginOptions {
218218

219219
For `"auto"`, the plugin uses `build.cssMinify` when it is set, otherwise it falls back to `build.minify`. In dev, `"auto"` defaults to `false`.
220220

221+
### Library builds (`.d.ts`)
222+
223+
For publishing an Angular library (the ng-packagr-style workflow, e.g. with
224+
Rolldown/tsdown), set `compilationMode: 'partial'`. This emits partial
225+
declarations (`ɵɵngDeclareComponent`, …) in the JavaScript output, and the
226+
plugin also augments the emitted `.d.ts` with Angular's Ivy type declarations
227+
(`static ɵfac`, `static ɵcmp`, …) so downstream consumers get full template
228+
type-checking against your library.
229+
230+
```typescript
231+
// vite.config.ts — Angular library build
232+
import { angular } from '@oxc-angular/vite'
233+
import dts from 'rolldown-plugin-dts' // or vite-plugin-dts / tsdown
234+
235+
export default defineConfig({
236+
plugins: [angular({ compilationMode: 'partial' }), dts()],
237+
build: { lib: { entry: 'src/public-api.ts', formats: ['es'] } },
238+
})
239+
```
240+
241+
The plugin does **not** generate the base `.d.ts` itself — a declaration
242+
generator (`rolldown-plugin-dts`, `vite-plugin-dts`, `tsdown`, or `tsc`) must
243+
produce them. The Angular members are then spliced into those files during
244+
`generateBundle`. The injected members reference `i0` (the `@angular/core`
245+
namespace), and the plugin adds `import * as i0 from "@angular/core";` to any
246+
`.d.ts` it augments.
247+
221248
## Vite Plugin Architecture
222249

223-
The Vite plugin consists of three sub-plugins:
250+
The Vite plugin consists of these sub-plugins:
224251

225252
1. **Transform Plugin** - Transforms Angular TypeScript files
226253
2. **HMR Plugin** - Handles hot module replacement for templates and styles
227254
3. **Styles Plugin** - Processes and encapsulates component styles
255+
4. **Dts Plugin** - Augments library `.d.ts` with Ivy type declarations (partial mode)
228256

229257
### HMR Routes
230258

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type { Plugin } from 'vite'
2+
import { describe, expect, it } from 'vitest'
3+
4+
import { angular } from '../vite-plugin/index.js'
5+
import { injectDtsDeclarations } from '../vite-plugin/utils/dts.js'
6+
7+
const COMPONENT_SOURCE = `
8+
import { Component } from '@angular/core';
9+
10+
@Component({
11+
selector: 'app-lib-button',
12+
template: '<button><ng-content></ng-content></button>',
13+
standalone: true,
14+
})
15+
export class LibButtonComponent {}
16+
`
17+
18+
describe('injectDtsDeclarations', () => {
19+
it('splices members into the matching class and adds the i0 import', () => {
20+
const source = `export declare class LibButtonComponent {\n}\n`
21+
const out = injectDtsDeclarations(source, [
22+
{
23+
className: 'LibButtonComponent',
24+
members:
25+
'static ɵfac: i0.ɵɵFactoryDeclaration<LibButtonComponent, never>;\n' +
26+
'static ɵcmp: i0.ɵɵComponentDeclaration<LibButtonComponent, "app-lib-button", never, {}, {}, never, ["*"], true, never>;',
27+
},
28+
])
29+
30+
expect(out).toContain('import * as i0 from "@angular/core";')
31+
expect(out).toContain('static ɵfac: i0.ɵɵFactoryDeclaration<LibButtonComponent, never>;')
32+
expect(out).toContain(
33+
'static ɵcmp: i0.ɵɵComponentDeclaration<LibButtonComponent, "app-lib-button", never, {}, {}, never, ["*"], true, never>;',
34+
)
35+
// Members land inside the class body, before its closing brace.
36+
const facIdx = out.indexOf('ɵfac')
37+
const braceIdx = out.indexOf('class LibButtonComponent')
38+
const closeIdx = out.lastIndexOf('}')
39+
expect(braceIdx).toBeLessThan(facIdx)
40+
expect(facIdx).toBeLessThan(closeIdx)
41+
})
42+
43+
it('is idempotent — re-running does not duplicate members', () => {
44+
const source = `export declare class Foo {\n}\n`
45+
const decls = [
46+
{ className: 'Foo', members: 'static ɵfac: i0.ɵɵFactoryDeclaration<Foo, never>;' },
47+
]
48+
const once = injectDtsDeclarations(source, decls)
49+
const twice = injectDtsDeclarations(once, decls)
50+
expect(twice).toBe(once)
51+
expect(once.match(/ɵfac/g)).toHaveLength(1)
52+
})
53+
54+
it('reuses an existing i0 import instead of adding a second one', () => {
55+
const source = 'import * as i0 from "@angular/core";\nexport declare class Foo {\n}\n'
56+
const out = injectDtsDeclarations(source, [
57+
{ className: 'Foo', members: 'static ɵfac: i0.ɵɵFactoryDeclaration<Foo, never>;' },
58+
])
59+
expect(out.match(/@angular\/core/g)).toHaveLength(1)
60+
})
61+
62+
it('keeps the i0 import after leading triple-slash references', () => {
63+
const source = '/// <reference types="node" />\nexport declare class Foo {\n}\n'
64+
const out = injectDtsDeclarations(source, [
65+
{ className: 'Foo', members: 'static ɵfac: i0.ɵɵFactoryDeclaration<Foo, never>;' },
66+
])
67+
expect(out.indexOf('/// <reference')).toBeLessThan(out.indexOf('import * as i0'))
68+
})
69+
70+
it('leaves files without a matching class untouched', () => {
71+
const source = `export declare class Other {\n}\n`
72+
const out = injectDtsDeclarations(source, [
73+
{ className: 'Missing', members: 'static ɵfac: i0.ɵɵFactoryDeclaration<Missing, never>;' },
74+
])
75+
expect(out).toBe(source)
76+
})
77+
})
78+
79+
describe('angular() dts plugin (#104)', () => {
80+
function getPlugins(compilationMode: 'full' | 'partial') {
81+
const plugins = angular({ compilationMode })
82+
const transform = plugins.find((p) => p.name === '@oxc-angular/vite')
83+
const dts = plugins.find((p) => p.name === '@oxc-angular/vite-dts')
84+
if (!transform || !dts) throw new Error('missing plugins')
85+
return { transform, dts }
86+
}
87+
88+
async function runTransform(transform: Plugin) {
89+
if (!transform.transform || typeof transform.transform === 'function') {
90+
throw new Error('expected transform handler')
91+
}
92+
await transform.transform.handler.call(
93+
{
94+
error(message: string) {
95+
throw new Error(message)
96+
},
97+
warn() {},
98+
} as any,
99+
COMPONENT_SOURCE,
100+
'lib-button.component.ts',
101+
)
102+
}
103+
104+
function makeBundle(dtsSource: string) {
105+
return {
106+
'index.d.ts': {
107+
type: 'asset' as const,
108+
fileName: 'index.d.ts',
109+
source: dtsSource,
110+
},
111+
}
112+
}
113+
114+
async function runGenerateBundle(dts: Plugin, bundle: unknown) {
115+
const hook = dts.generateBundle
116+
if (!hook) throw new Error('expected generateBundle')
117+
const fn = typeof hook === 'function' ? hook : hook.handler
118+
await fn.call({} as any, {} as any, bundle as any, false)
119+
}
120+
121+
it('augments .d.ts assets in partial mode', async () => {
122+
const { transform, dts } = getPlugins('partial')
123+
await runTransform(transform)
124+
125+
const bundle = makeBundle('export declare class LibButtonComponent {\n}\n')
126+
await runGenerateBundle(dts, bundle)
127+
128+
const out = bundle['index.d.ts'].source as string
129+
expect(out).toContain('import * as i0 from "@angular/core";')
130+
expect(out).toContain('static ɵfac: i0.ɵɵFactoryDeclaration<LibButtonComponent')
131+
expect(out).toContain('static ɵcmp: i0.ɵɵComponentDeclaration<LibButtonComponent')
132+
})
133+
134+
it('does not touch declarations in full (app) mode', async () => {
135+
const { transform, dts } = getPlugins('full')
136+
await runTransform(transform)
137+
138+
const original = 'export declare class LibButtonComponent {\n}\n'
139+
const bundle = makeBundle(original)
140+
await runGenerateBundle(dts, bundle)
141+
142+
expect(bundle['index.d.ts'].source).toBe(original)
143+
})
144+
})

napi/angular-compiler/vite-plugin/index.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
locateTemplateInArgs,
4444
locateTemplateStringFor,
4545
} from './utils/decorator-fields.js'
46+
import { injectDtsDeclarations } from './utils/dts.js'
4647

4748
/**
4849
* Plugin options for the Angular Vite plugin.
@@ -261,6 +262,18 @@ export function angular(options: PluginOptions = {}): Plugin[] {
261262
// can dispatch an HMR update instead of a full reload.
262263
const componentMetadataCache = new Map<string, string>()
263264

265+
// Angular Ivy `.d.ts` static member declarations collected across the build,
266+
// keyed by module id. Populated during `transform` in `compilationMode:
267+
// 'partial'` (library) builds and consumed by `dtsPlugin`'s `generateBundle`
268+
// to augment the declaration files a separate dts generator emits.
269+
//
270+
// Keyed by module (not class name) so `vite build --watch` rebuilds can evict
271+
// a module's prior declarations before re-transforming it. Otherwise removing
272+
// a decorator — which makes the quick decorator check early-return, skipping
273+
// the transform entirely — would leave the old `ɵfac`/`ɵcmp` entries in place
274+
// and `generateBundle` would re-inject Ivy metadata into a now-plain class.
275+
const collectedDtsDeclarations = new Map<string, Array<{ className: string; members: string }>>()
276+
264277
function getMinifyComponentStyles(context?: {
265278
environment?: { config?: { build?: ResolvedConfig['build'] } }
266279
}): boolean {
@@ -601,6 +614,14 @@ export function angular(options: PluginOptions = {}): Plugin[] {
601614
return
602615
}
603616

617+
// Library builds: evict any declarations this module contributed on a
618+
// previous (watch) pass before re-deriving them below. Done ahead of
619+
// the decorator early-return so a class that just lost its decorator
620+
// doesn't keep stale Ivy metadata in the regenerated `.d.ts`.
621+
if (pluginOptions.compilationMode === 'partial') {
622+
collectedDtsDeclarations.delete(id)
623+
}
624+
604625
// Quick check for Angular decorators - avoids parsing files without them
605626
// OXC handles @Component, @Directive, @NgModule, @Injectable, and @Pipe
606627
const hasAngularDecorator =
@@ -672,6 +693,18 @@ export function angular(options: PluginOptions = {}): Plugin[] {
672693
this.warn(warning.message)
673694
}
674695

696+
// Library builds: stash the Ivy `.d.ts` member declarations for this
697+
// file so `dtsPlugin` can splice them into the emitted declarations.
698+
if (pluginOptions.compilationMode === 'partial' && result.dtsDeclarations.length > 0) {
699+
collectedDtsDeclarations.set(
700+
id,
701+
result.dtsDeclarations.map((decl) => ({
702+
className: decl.className,
703+
members: decl.members,
704+
})),
705+
)
706+
}
707+
675708
// Track component IDs for HMR — one entry per @Component class.
676709
if (pluginOptions.liveReload) {
677710
// templateUpdates is keyed by `filePath@ClassName` (NAPI HashMap → JS object).
@@ -933,6 +966,58 @@ export function angular(options: PluginOptions = {}): Plugin[] {
933966
/**
934967
* Plugin to encapsulate component styles.
935968
*/
969+
/**
970+
* Augment library `.d.ts` files with Angular's Ivy type declarations.
971+
*
972+
* Vite/Rolldown don't emit declarations themselves — a separate dts
973+
* generator (rolldown-plugin-dts, vite-plugin-dts, tsdown, `tsc`) produces
974+
* the base `.d.ts`. This plugin runs after them (`enforce: 'post'`) and
975+
* splices the static `ɵfac`/`ɵcmp`/… members collected during `transform`
976+
* into the matching classes so consumers get full template type-checking.
977+
*
978+
* Only active in `compilationMode: 'partial'` (library) builds; app builds
979+
* collect nothing, so this is a no-op there.
980+
*/
981+
function dtsPlugin(): Plugin {
982+
return {
983+
name: '@oxc-angular/vite-dts',
984+
enforce: 'post',
985+
generateBundle(_outputOptions, bundle) {
986+
if (pluginOptions.compilationMode !== 'partial') return
987+
if (collectedDtsDeclarations.size === 0) return
988+
989+
// Flatten every module's declarations into a class-name-keyed list.
990+
// A library publishes one class per name; if names ever collide the
991+
// last module wins, matching the previous (class-name-keyed) behavior.
992+
const byClassName = new Map<string, string>()
993+
for (const moduleDecls of collectedDtsDeclarations.values()) {
994+
for (const decl of moduleDecls) {
995+
byClassName.set(decl.className, decl.members)
996+
}
997+
}
998+
const declarations = Array.from(byClassName, ([className, members]) => ({
999+
className,
1000+
members,
1001+
}))
1002+
1003+
for (const file of Object.values(bundle)) {
1004+
if (file.type !== 'asset') continue
1005+
if (!file.fileName.endsWith('.d.ts')) continue
1006+
1007+
const source =
1008+
typeof file.source === 'string'
1009+
? file.source
1010+
: Buffer.from(file.source).toString('utf-8')
1011+
1012+
const augmented = injectDtsDeclarations(source, declarations)
1013+
if (augmented !== source) {
1014+
file.source = augmented
1015+
}
1016+
}
1017+
},
1018+
}
1019+
}
1020+
9361021
function stylesPlugin(): Plugin {
9371022
return {
9381023
name: '@oxc-angular/vite-styles',
@@ -965,6 +1050,7 @@ export function angular(options: PluginOptions = {}): Plugin[] {
9651050
return [
9661051
angularPlugin(),
9671052
stylesPlugin(),
1053+
dtsPlugin(),
9681054
angularLinkerPlugin(),
9691055
pluginOptions.jit &&
9701056
jitPlugin({

0 commit comments

Comments
 (0)