diff --git a/napi/angular-compiler/README.md b/napi/angular-compiler/README.md index c5bf62d04..d8c058a26 100644 --- a/napi/angular-compiler/README.md +++ b/napi/angular-compiler/README.md @@ -198,7 +198,8 @@ interface PluginOptions { // Project configuration tsconfig?: string workspaceRoot?: string - // Application's Angular version, used for app code and linked libraries (default: latest) + // Application's Angular version, used for app code and linked libraries + // (default: the installed @angular/core version, else latest) angularVersion?: { major: number; minor: number; patch: number } // Features diff --git a/napi/angular-compiler/test/angular-version.test.ts b/napi/angular-compiler/test/angular-version.test.ts new file mode 100644 index 000000000..5d70e0134 --- /dev/null +++ b/napi/angular-compiler/test/angular-version.test.ts @@ -0,0 +1,166 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { normalizePath } from 'vite' +import { afterEach, describe, expect, it } from 'vitest' + +import { angular, type PluginOptions } from '../vite-plugin/index.js' +import { + installedAngularVersion, + parseAngularVersion, +} from '../vite-plugin/utils/angular-version.js' + +const dirs: string[] = [] + +/** A project directory with `@angular/core` at `version` installed, if any. */ +function project(version?: string): string { + const dir = mkdtempSync(join(tmpdir(), 'angular-version-')) + dirs.push(dir) + if (version) install(dir, version) + return dir +} + +/** `@angular/core` at `version` in `dir/node_modules`. */ +function install(dir: string, version: string): void { + const core = join(dir, 'node_modules/@angular/core') + mkdirSync(core, { recursive: true }) + writeFileSync(join(core, 'package.json'), JSON.stringify({ name: '@angular/core', version })) +} + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +describe('parseAngularVersion', () => { + it('reads stable and prerelease versions', () => { + expect(parseAngularVersion('22.1.3')).toEqual({ major: 22, minor: 1, patch: 3 }) + expect(parseAngularVersion('22.2.0-next.3')).toEqual({ + major: 22, + minor: 2, + patch: 0, + prerelease: 'next.3', + }) + expect(parseAngularVersion('21.0.0+sha-abc')).toEqual({ major: 21, minor: 0, patch: 0 }) + }) + + it("leaves out a build of Angular's own repository and anything else", () => { + expect(parseAngularVersion('0.0.0-PLACEHOLDER')).toBeUndefined() + expect(parseAngularVersion('22')).toBeUndefined() + expect(parseAngularVersion(undefined)).toBeUndefined() + }) +}) + +describe('installedAngularVersion', () => { + it('finds @angular/core from a nested directory', () => { + const root = project('21.2.1') + const nested = join(root, 'apps/app') + mkdirSync(nested, { recursive: true }) + expect(installedAngularVersion(nested)).toEqual({ major: 21, minor: 2, patch: 1 }) + }) + + it('resolves a relative root first', () => { + // `.` in an app whose packages are hoisted to the workspace above it. + const root = project('21.2.1') + const nested = join(root, 'apps/app') + mkdirSync(nested, { recursive: true }) + const cwd = process.cwd() + process.chdir(nested) + try { + expect(installedAngularVersion('.')).toEqual({ major: 21, minor: 2, patch: 1 }) + } finally { + process.chdir(cwd) + } + }) + + it('gives undefined without a readable package', () => { + const root = project() + mkdirSync(join(root, 'node_modules/@angular/core'), { recursive: true }) + writeFileSync(join(root, 'node_modules/@angular/core/package.json'), '{') + expect(installedAngularVersion(root)).toBeUndefined() + }) +}) + +const SOURCE = `import { Component } from '@angular/core'; +import { Cmp } from './cmp'; + +@Component({ + selector: 'app-root', + template: '@defer { }', + deferredImports: [Cmp], +}) +export class AppComponent {} +` + +/** The plugin's output for `SOURCE` in `root`, or the error it reports. */ +async function transform(options: PluginOptions, root: string): Promise { + const plugin = angular(options).find((candidate) => candidate.name === '@oxc-angular/vite') + if (!plugin?.transform || typeof plugin.transform === 'function') { + throw new Error('Expected plugin transform handler') + } + const configResolved = plugin.configResolved as unknown as + | { handler: (config: unknown) => void } + | ((config: unknown) => void) + const config = { root, build: {}, isProduction: false } + if (typeof configResolved === 'function') configResolved(config) + else configResolved?.handler(config) + try { + const result = await plugin.transform.handler.call( + { + error(message: string) { + throw new Error(message) + }, + warn() {}, + } as any, + SOURCE, + normalizePath(join(root, 'src/app.component.ts')), + ) + return (result as { code: string }).code + } catch (error) { + return `ERROR ${(error as Error).message}` + } +} + +describe('angularVersion default', () => { + it('compiles the array deferredImports of an Angular 22.1 project', async () => { + const root = project('22.1.0') + const code = await transform({ workspaceRoot: root }, root) + expect(code).not.toContain('ERROR') + expect(code).toContain('import("./cmp")') + }) + + it('reports the array deferredImports of an Angular 22.2 project', async () => { + const root = project('22.2.0') + const code = await transform({ workspaceRoot: root }, root) + expect(code).toContain("'deferredImports' must be an object mapping") + }) + + it('prefers the angularVersion option over the installed version', async () => { + const root = project('22.2.0') + const code = await transform( + { workspaceRoot: root, angularVersion: { major: 22, minor: 1, patch: 0 } }, + root, + ) + expect(code).not.toContain('ERROR') + }) + + it("reads the version installed for Vite's root, not the workspace root", async () => { + // An app with its own `@angular/core` in a workspace on a newer one. + const workspace = project('22.2.0') + const app = join(workspace, 'apps/old') + install(app, '22.1.0') + const code = await transform({ workspaceRoot: workspace }, app) + expect(code).not.toContain('ERROR') + }) + + it('links dependencies for the same version', async () => { + const workspace = project('22.2.0') + const app = join(workspace, 'apps/old') + install(app, '22.1.0') + const linker = angular({ workspaceRoot: workspace }).find( + (candidate) => candidate.name === '@oxc-angular/vite-linker', + ) as any + const config = linker.config({ root: app }, { command: 'build', mode: 'production' }) + expect(config.optimizeDeps.rolldownOptions.plugins[0].name).toBe('angular-linker@22.1.0') + }) +}) diff --git a/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts b/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts index 09c7fedf4..dbf02af97 100644 --- a/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts @@ -1,3 +1,5 @@ +import { resolve } from 'node:path' + import { linkAngularPackage, type AngularVersion } from '#binding' import type { Plugin, ResolvedConfig } from 'vite' @@ -100,8 +102,11 @@ async function linkCode( } export interface AngularLinkerPluginOptions { - /** Angular version of the application, forwarded to the linker. Assumes latest when unset. */ - angularVersion?: AngularVersion + /** + * Angular version of the application, forwarded to the linker, or a function + * giving it for Vite's root. Assumes latest when unset. + */ + angularVersion?: AngularVersion | ((root: string) => AngularVersion | undefined) /** Return source maps from linked code back to the package file. Default: false */ sourcemap?: boolean } @@ -124,10 +129,15 @@ function optimizerPluginName( } export function angularLinkerPlugin({ - angularVersion, + angularVersion: angularVersionOption, sourcemap = false, }: AngularLinkerPluginOptions = {}): Plugin { let optimizeDepsExclude: readonly string[] = [] + // Recomputed for the config's root in `config`. + let angularVersion = + typeof angularVersionOption === 'function' + ? angularVersionOption(process.cwd()) + : angularVersionOption return { name: '@oxc-angular/vite-linker', @@ -137,7 +147,13 @@ export function angularLinkerPlugin({ optimizeDepsExclude = config.optimizeDeps?.exclude ?? [] }, - config(_, { command }) { + config(userConfig, { command }) { + if (typeof angularVersionOption === 'function') { + // The root Vite resolves the config with (`resolveConfig`). + angularVersion = angularVersionOption( + userConfig.root ? resolve(userConfig.root) : process.cwd(), + ) + } return { optimizeDeps: { rolldownOptions: { diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 9deb9cc66..c2f80f7bc 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -36,6 +36,7 @@ import { buildOptimizerPlugin } from './angular-build-optimizer-plugin.js' import { jitPlugin } from './angular-jit-plugin.js' import { angularLinkerPlugin } from './angular-linker-plugin.js' import { ssrManifestPlugin } from './angular-ssr-manifest-plugin.js' +import { installedAngularVersion } from './utils/angular-version.js' import { injectDtsDeclarations } from './utils/dts.js' /** @@ -103,7 +104,10 @@ export interface PluginOptions { * * Also used when linking partially compiled libraries (`ɵɵngDeclare*`). * - * When not set, assumes latest Angular version (v20+ behavior). + * Default: the version of `@angular/core` installed for Vite's `root` + * (found in `node_modules` from there up), like the package the app + * imports. When none is found, or it is a `0.0.0-...` build, the latest + * Angular version is assumed. * * @example * ```ts @@ -322,6 +326,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { // object form turns them on; `sourceMap: true` only covers scripts. vendorSourceMap: typeof options.sourceMap === 'object' && (options.sourceMap.vendor ?? false), fileReplacements, + // Without the option, the installed version once Vite's root is known. angularVersion: options.angularVersion, emitClassMetadata: options.emitClassMetadata ?? true, compilationMode: options.compilationMode ?? 'full', @@ -330,6 +335,17 @@ export function angular(options: PluginOptions = {}): Plugin[] { tsconfig: options.tsconfig, } + // The `angularVersion` option, else the `@angular/core` version installed + // for the Vite root `root` (looked up once per root). + let installed: { root: string; version: AngularVersion | undefined } | undefined + function angularVersionFor(root: string): AngularVersion | undefined { + if (options.angularVersion) return options.angularVersion + if (!installed || installed.root !== root) { + installed = { root, version: installedAngularVersion(root) } + } + return installed.version + } + let resolvedConfig: ResolvedConfig let viteServer: ViteDevServer | undefined let watchMode = false @@ -743,6 +759,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { }, configResolved(config) { resolvedConfig = config + pluginOptions.angularVersion = angularVersionFor(config.root ?? workspaceRoot) }, outputOptions(options) { outputMinify = options.minify @@ -1724,7 +1741,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { angularPlugin(), dtsPlugin(), angularLinkerPlugin({ - angularVersion: pluginOptions.angularVersion, + angularVersion: angularVersionFor, // The linker only sees node_modules files, so it follows the vendor setting // (Angular CLI only maps third-party code when both scripts and vendor are on). sourcemap: pluginOptions.sourceMap && pluginOptions.vendorSourceMap, diff --git a/napi/angular-compiler/vite-plugin/utils/angular-version.ts b/napi/angular-compiler/vite-plugin/utils/angular-version.ts new file mode 100644 index 000000000..8cb231c01 --- /dev/null +++ b/napi/angular-compiler/vite-plugin/utils/angular-version.ts @@ -0,0 +1,57 @@ +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' + +import type { AngularVersion } from '#binding' + +/** + * The version of `@angular/core` installed for the project at `root` (Vite's + * root), the default of the `angularVersion` option. `undefined` when it isn't found or + * is a build of Angular's own repository (`0.0.0-...`): the compiler then + * targets the latest version. + */ +export function installedAngularVersion(root: string): AngularVersion | undefined { + // A relative `dirname` chain would stop at `.`. + const file = findAngularCorePackageJson(resolve(root)) + if (!file) return undefined + try { + return parseAngularVersion(JSON.parse(readFileSync(file, 'utf8')).version) + } catch { + return undefined + } +} + +/** `22.1.0-next.3` as an `AngularVersion`; build metadata (`+...`) is dropped. */ +export function parseAngularVersion(version: unknown): AngularVersion | undefined { + if (typeof version !== 'string') return undefined + const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/.exec(version.trim()) + if (!match) return undefined + const [, major, minor, patch, prerelease] = match + if (Number(major) === 0) return undefined + return { + major: Number(major), + minor: Number(minor), + patch: Number(patch), + ...(prerelease ? { prerelease } : {}), + } +} + +/** + * Looked up in `node_modules` rather than with `require.resolve`, which also + * follows `NODE_PATH` (set by pnpm to its hoisted store), like zone.js in + * `angular-async-downlevel-plugin.ts`. + */ +function findAngularCorePackageJson(root: string): string | undefined { + if (process.versions['pnp']) { + try { + return createRequire(join(root, 'package.json')).resolve('@angular/core/package.json') + } catch { + return undefined + } + } + for (let dir = root; ; dir = dirname(dir)) { + const file = join(dir, 'node_modules/@angular/core/package.json') + if (existsSync(file)) return file + if (dirname(dir) === dir) return undefined + } +}