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
3 changes: 2 additions & 1 deletion napi/angular-compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
166 changes: 166 additions & 0 deletions napi/angular-compiler/test/angular-version.test.ts
Original file line number Diff line number Diff line change
@@ -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 { <cmp-a /> }',
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<string> {
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')
})
})
24 changes: 20 additions & 4 deletions napi/angular-compiler/vite-plugin/angular-linker-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { resolve } from 'node:path'

import { linkAngularPackage, type AngularVersion } from '#binding'
import type { Plugin, ResolvedConfig } from 'vite'

Expand Down Expand Up @@ -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
}
Expand All @@ -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',
Expand All @@ -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: {
Expand Down
21 changes: 19 additions & 2 deletions napi/angular-compiler/vite-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
57 changes: 57 additions & 0 deletions napi/angular-compiler/vite-plugin/utils/angular-version.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading