diff --git a/packages/angular/build/src/builders/library/pipeline/assets.ts b/packages/angular/build/src/builders/library/pipeline/assets.ts index 90c6d5c10fa4..ae87edb8d4e3 100644 --- a/packages/angular/build/src/builders/library/pipeline/assets.ts +++ b/packages/angular/build/src/builders/library/pipeline/assets.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { statSync } from 'node:fs'; +import { lstatSync, statSync } from 'node:fs'; import path from 'node:path'; import picomatch from 'picomatch'; import type { NormalizedAssetsPattern } from '../../../utils'; @@ -38,6 +38,22 @@ export interface AssetMatcher { isMatch: picomatch.Matcher; } +/** + * Normalizes a glob pattern to match `tinyglobby` semantics by stripping trailing slashes, + * expanding directory patterns with `/**`, and normalizing POSIX segments. + * + * @param pattern The raw glob pattern string. + * @returns The normalized POSIX glob pattern. + */ +function normalizeGlobPattern(pattern: string): string { + let result = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern; + if (pattern.endsWith('/') || !picomatch.scan(result).isGlob) { + result += '/**'; + } + + return path.posix.normalize(result); +} + /** * Compiles picomatch matchers and POSIX input prefixes for each asset configuration. * @@ -46,9 +62,13 @@ export interface AssetMatcher { */ export function createAssetMatchers(assets: NormalizedAssetsPattern[]): readonly AssetMatcher[] { return assets.map((asset) => { - const isMatch = picomatch(asset.glob, { + const ignore = [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])] + .filter((pattern) => pattern.length > 0) + .map(normalizeGlobPattern); + const isMatch = picomatch(asset.glob ? path.posix.normalize(asset.glob) : [], { dot: true, - ignore: [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])], + windows: false, + ignore, }); const prefix = asset.input.endsWith('/') ? asset.input : `${asset.input}/`; @@ -79,21 +99,32 @@ export async function collectAssetsToEmit( if (modifiedFiles) { const matchers = assetMatchers ?? createAssetMatchers(assets); - const filesToEmit: DiskOutputFile[] = []; - const filesToDelete: string[] = []; + const filesToEmit = new Map(); + const filesToDelete = new Set(); for (const file of modifiedFiles) { - let isFile: boolean | undefined; - - if (emittedAssets.has(file)) { - isFile = statSync(file, { throwIfNoEntry: false })?.isFile() ?? false; - if (!isFile) { - filesToDelete.push(file); + let isFileStat: boolean | undefined; + let isFileLstat: boolean | undefined; + + if (!emittedAssets.has(file) && emittedAssets.size > 0) { + const dirPrefix = file.endsWith('/') ? file : `${file}/`; + let isDeletedDir: boolean | undefined; + for (const emittedFile of emittedAssets.keys()) { + if (emittedFile.startsWith(dirPrefix)) { + if (!(isDeletedDir ??= !statSync(file, { throwIfNoEntry: false })?.isDirectory())) { + break; + } + filesToDelete.add(emittedFile); + } + } + if (isDeletedDir !== undefined) { continue; } } - for (const { asset, prefix, isMatch } of matchers) { + let matched = false; + for (let matcherIndex = 0; matcherIndex < matchers.length; matcherIndex++) { + const { asset, prefix, isMatch } = matchers[matcherIndex]; if (!file.startsWith(prefix)) { continue; } @@ -103,24 +134,41 @@ export async function collectAssetsToEmit( continue; } - isFile ??= statSync(file, { throwIfNoEntry: false })?.isFile() ?? false; + const isFile = asset.followSymlinks + ? (isFileStat ??= statSync(file, { throwIfNoEntry: false })?.isFile() ?? false) + : (isFileLstat ??= lstatSync(file, { throwIfNoEntry: false })?.isFile() ?? false); if (!isFile) { - break; + continue; } - filesToEmit.push(createDiskOutputFile(file, path.posix.join(asset.output, relative))); + matched = true; + const outputFile = createDiskOutputFile(file, path.posix.join(asset.output, relative)); + const existing = filesToEmit.get(outputFile.path); + if (!existing || matcherIndex >= existing.matcherIndex) { + filesToEmit.set(outputFile.path, { file: outputFile, matcherIndex }); + } + } + + if (emittedAssets.has(file) && !matched) { + filesToDelete.add(file); } } - return { filesToEmit, filesToDelete }; + return { + filesToEmit: Array.from(filesToEmit.values(), ({ file }) => file), + filesToDelete: Array.from(filesToDelete), + }; } const resolvedAssets = await resolveAssets(assets, workspaceRoot); + const filesToEmit = new Map(); + for (const { source, destination } of resolvedAssets) { + const outputFile = createDiskOutputFile(source, destination); + filesToEmit.set(outputFile.path, outputFile); + } return { - filesToEmit: resolvedAssets.map(({ source, destination }) => - createDiskOutputFile(source, destination), - ), + filesToEmit: Array.from(filesToEmit.values()), filesToDelete: [], }; } diff --git a/packages/angular/build/src/builders/library/pipeline/assets_spec.ts b/packages/angular/build/src/builders/library/pipeline/assets_spec.ts new file mode 100644 index 000000000000..52a9f11dee53 --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/assets_spec.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { toPosixPath } from '../../../utils/path'; +import { collectAssetsToEmit, createAssetMatchers } from './assets'; + +describe('library pipeline assets', () => { + let tempRoot: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = toPosixPath(await mkdtemp(path.join(baseTmpDir, 'angular-cli-assets-spec-'))); + }); + + afterEach(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + + describe('createAssetMatchers', () => { + it('should normalize patterns, expand directories in ignore, and skip empty strings', () => { + const matchers = createAssetMatchers([ + { + glob: './**/*.txt', + input: `${tempRoot}/assets`, + output: 'assets', + ignore: ['', 'ignored-dir', './ignored-dir-2/', 'specific.txt'], + }, + ]); + + expect(matchers).toHaveSize(1); + const [matcher] = matchers; + + expect(matcher.isMatch('keep.txt')).toBeTrue(); + expect(matcher.isMatch('nested/keep.txt')).toBeTrue(); + expect(matcher.isMatch('.hidden.txt')).toBeTrue(); + expect(matcher.isMatch('specific.txt')).toBeFalse(); + expect(matcher.isMatch('ignored-dir/file.txt')).toBeFalse(); + expect(matcher.isMatch('ignored-dir/sub/file.txt')).toBeFalse(); + expect(matcher.isMatch('ignored-dir-2/file.txt')).toBeFalse(); + expect(matcher.isMatch('.gitkeep')).toBeFalse(); + expect(matcher.isMatch('nested/.DS_Store')).toBeFalse(); + }); + + it('should not throw when glob is an empty string', () => { + const matchers = createAssetMatchers([ + { + glob: '', + input: `${tempRoot}/assets`, + output: 'assets', + }, + ]); + + expect(matchers).toHaveSize(1); + expect(matchers[0].isMatch('any.txt')).toBeFalse(); + }); + }); + + describe('collectAssetsToEmit', () => { + it('should deduplicate resolved assets by destination path with later patterns overriding earlier ones', async () => { + const defaultDir = `${tempRoot}/lib`; + const customDir = `${tempRoot}/docs`; + await mkdir(defaultDir, { recursive: true }); + await mkdir(customDir, { recursive: true }); + + const defaultReadme = `${defaultDir}/README.md`; + const customReadme = `${customDir}/README.md`; + await writeFile(defaultReadme, '# Default'); + await writeFile(customReadme, '# Custom'); + + const assets = [ + { glob: 'README.md', input: defaultDir, output: '.' }, + { glob: 'README.md', input: customDir, output: '.' }, + ]; + + const initialResult = await collectAssetsToEmit(assets, tempRoot, new Map()); + expect(initialResult.filesToEmit).toEqual([ + { + origin: 'disk', + source: customReadme, + path: 'README.md', + }, + ]); + + // Watch mode: even if customReadme appears before defaultReadme in modifiedFiles, + // the later asset pattern (customReadme) should still take precedence. + const watchResult = await collectAssetsToEmit( + assets, + tempRoot, + new Map(), + new Set([customReadme, defaultReadme]), + ); + expect(watchResult.filesToEmit).toEqual([ + { + origin: 'disk', + source: customReadme, + path: 'README.md', + }, + ]); + }); + + it('should respect followSymlinks in watch mode', async () => { + const assetsDir = `${tempRoot}/assets`; + await mkdir(assetsDir, { recursive: true }); + + const targetFile = `${tempRoot}/external.txt`; + const symlinkFile = `${assetsDir}/linked.txt`; + await writeFile(targetFile, 'external'); + await symlink(targetFile, symlinkFile); + + const assetsNoFollow = [ + { glob: '**/*.txt', input: assetsDir, output: 'out', followSymlinks: false }, + ]; + const noFollowResult = await collectAssetsToEmit( + assetsNoFollow, + tempRoot, + new Map(), + new Set([symlinkFile]), + ); + expect(noFollowResult.filesToEmit).toEqual([]); + + const assetsFollow = [ + { glob: '**/*.txt', input: assetsDir, output: 'out', followSymlinks: true }, + ]; + const followResult = await collectAssetsToEmit( + assetsFollow, + tempRoot, + new Map(), + new Set([symlinkFile]), + ); + expect(followResult.filesToEmit).toEqual([ + { + origin: 'disk', + source: symlinkFile, + path: 'out/linked.txt', + }, + ]); + }); + + it('should queue nested emitted assets for deletion when a deleted directory is reported in watch mode', async () => { + const assetsDir = `${tempRoot}/assets`; + const subDir = `${assetsDir}/sub`; + const keepDir = `${assetsDir}/keep-dir`; + await mkdir(keepDir, { recursive: true }); + + const nestedFileA = `${subDir}/a.txt`; + const nestedFileB = `${subDir}/deep/b.txt`; + const keepFile = `${keepDir}/keep.txt`; + await writeFile(keepFile, 'keep'); + + const emittedAssets = new Map>([ + [nestedFileA, new Set(['out/sub/a.txt'])], + [nestedFileB, new Set(['out/sub/deep/b.txt'])], + [keepFile, new Set(['out/keep-dir/keep.txt'])], + ]); + + const assets = [{ glob: '**/*.txt', input: assetsDir, output: 'out' }]; + + // subDir does not exist on disk; keepDir still exists on disk + const result = await collectAssetsToEmit( + assets, + tempRoot, + emittedAssets, + new Set([subDir, keepDir]), + ); + + expect(result.filesToEmit).toEqual([]); + expect(result.filesToDelete).toEqual([nestedFileA, nestedFileB]); + }); + }); +});