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
86 changes: 67 additions & 19 deletions packages/angular/build/src/builders/library/pipeline/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Comment thread
alan-agius4 marked this conversation as resolved.

/**
* Compiles picomatch matchers and POSIX input prefixes for each asset configuration.
*
Expand All @@ -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}/`;

Expand Down Expand Up @@ -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<string, { file: DiskOutputFile; matcherIndex: number }>();
const filesToDelete = new Set<string>();

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;
}
Expand All @@ -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<string, DiskOutputFile>();
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: [],
};
}
179 changes: 179 additions & 0 deletions packages/angular/build/src/builders/library/pipeline/assets_spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<string>>([
[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]);
});
});
});
Loading