Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion napi/angular-compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
144 changes: 144 additions & 0 deletions napi/angular-compiler/test/dts.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<button><ng-content></ng-content></button>',
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<LibButtonComponent, never>;\n' +
'static ɵcmp: i0.ɵɵComponentDeclaration<LibButtonComponent, "app-lib-button", never, {}, {}, never, ["*"], true, never>;',
},
])

expect(out).toContain('import * as i0 from "@angular/core";')
expect(out).toContain('static ɵfac: i0.ɵɵFactoryDeclaration<LibButtonComponent, never>;')
expect(out).toContain(
'static ɵcmp: i0.ɵɵComponentDeclaration<LibButtonComponent, "app-lib-button", never, {}, {}, never, ["*"], true, never>;',
)
// 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<Foo, never>;' },
]
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<Foo, never>;' },
])
expect(out.match(/@angular\/core/g)).toHaveLength(1)
})

it('keeps the i0 import after leading triple-slash references', () => {
const source = '/// <reference types="node" />\nexport declare class Foo {\n}\n'
const out = injectDtsDeclarations(source, [
{ className: 'Foo', members: 'static ɵfac: i0.ɵɵFactoryDeclaration<Foo, never>;' },
])
expect(out.indexOf('/// <reference')).toBeLessThan(out.indexOf('import * as i0'))
})

it('leaves files without a matching class untouched', () => {
const source = `export declare class Other {\n}\n`
const out = injectDtsDeclarations(source, [
{ className: 'Missing', members: 'static ɵfac: i0.ɵɵFactoryDeclaration<Missing, never>;' },
])
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<LibButtonComponent')
expect(out).toContain('static ɵcmp: i0.ɵɵComponentDeclaration<LibButtonComponent')
})

it('does not touch declarations in full (app) mode', async () => {
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)
})
})
86 changes: 86 additions & 0 deletions napi/angular-compiler/vite-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -261,6 +262,18 @@ export function angular(options: PluginOptions = {}): Plugin[] {
// can dispatch an HMR update instead of a full reload.
const componentMetadataCache = new Map<string, string>()

// 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<string, Array<{ className: string; members: string }>>()

function getMinifyComponentStyles(context?: {
environment?: { config?: { build?: ResolvedConfig['build'] } }
}): boolean {
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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<string, string>()
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',
Expand Down Expand Up @@ -965,6 +1050,7 @@ export function angular(options: PluginOptions = {}): Plugin[] {
return [
angularPlugin(),
stylesPlugin(),
dtsPlugin(),
angularLinkerPlugin(),
pluginOptions.jit &&
jitPlugin({
Expand Down
Loading
Loading