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
42 changes: 9 additions & 33 deletions packages/angular/build/src/builders/library/pipeline/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { lstatSync, statSync } from 'node:fs';
import path from 'node:path';
import picomatch from 'picomatch';
import type { NormalizedAssetsPattern } from '../../../utils';
import { DEFAULT_ASSET_IGNORE, resolveAssets } from '../../../utils/resolve-assets';
import { normalizeAssetIgnorePatterns, resolveAssets } from '../../../utils/resolve-assets';
import { type DiskOutputFile, createDiskOutputFile } from './utils';

/**
Expand Down Expand Up @@ -38,30 +38,6 @@ export interface AssetMatcher {
isMatch: picomatch.Matcher;
}

/**
* Normalizes an ignore glob pattern for `picomatch` to match `tinyglobby` directory-exclusion
* semantics by stripping trailing slashes, normalizing POSIX segments, and expanding patterns
* with `/**` (returning both `normalized` and `${normalized}/**` for single-wildcard suffixes
* because `picomatch` does not make trailing `/**` optional after `*`).
*
* @param pattern The raw ignore glob pattern string.
* @returns The normalized POSIX glob patterns.
*/
function normalizeGlobPattern(pattern: string): string[] {
const trimmed = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern;
const normalized = path.posix.normalize(trimmed);

if (!normalized.endsWith('*')) {
return [`${normalized}/**`];
}

if (normalized.endsWith('/**')) {
return [normalized];
}

return [normalized, `${normalized}/**`];
}

/**
* Resolves the candidate POSIX source file path within `matcher` that would map to `outputPath`,
* or `undefined` if `outputPath` is outside `matcher.asset.output` or does not match `matcher`.
Expand Down Expand Up @@ -96,14 +72,14 @@ function isMatchingAssetFile(file: string, followSymlinks: boolean | undefined):
export function createAssetMatchers(assets: NormalizedAssetsPattern[]): readonly AssetMatcher[] {
return assets.map((asset) => {
const { glob: rawGlob, ignore: rawIgnore, input } = asset;
const ignore = [...DEFAULT_ASSET_IGNORE, ...(rawIgnore ?? [])]
.filter((pattern) => pattern.length > 0)
.flatMap(normalizeGlobPattern);
const isMatch = picomatch(rawGlob ? path.posix.normalize(rawGlob) : [], {
dot: true,
windows: false,
ignore,
});
const isMatch = picomatch(
rawGlob && !rawGlob.endsWith('/') ? path.posix.normalize(rawGlob) : [],
{
dot: true,
windows: false,
ignore: normalizeAssetIgnorePatterns(rawIgnore),
},
);
const prefix = input.endsWith('/') ? input : `${input}/`;

return { asset, prefix, isMatch };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,21 @@ describe('library pipeline assets', () => {
expect(matchers).toHaveSize(1);
expect(matchers[0].isMatch('any.txt')).toBeFalse();
});

it('should not match files when glob has a trailing slash', () => {
const matchers = createAssetMatchers([
{
glob: 'sub/',
input: `${tempRoot}/assets`,
output: 'assets',
ignore: ['ignored.txt'],
},
]);

expect(matchers).toHaveSize(1);
expect(matchers[0].isMatch('sub')).toBeFalse();
expect(matchers[0].isMatch('sub/file.txt')).toBeFalse();
});
});

describe('collectAssetsToEmit', () => {
Expand Down
59 changes: 59 additions & 0 deletions packages/angular/build/src/utils/normalize-asset-patterns_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,63 @@ describe('resolveAssets', () => {
resolveAssets([{ glob: '../*.txt', input: 'src', output: '.' }], tempRoot),
).toBeRejectedWithError('An asset cannot be written to a location outside of the output path.');
});

it('should not expand directory patterns in glob whether or not ignore is present', async () => {
await mkdir(join(tempRoot, 'src/assets/sub'), { recursive: true });
await writeFile(join(tempRoot, 'src/assets/sub/file.txt'), 'content');
await writeFile(join(tempRoot, 'src/assets/sub/ignored.txt'), 'ignored');
await writeFile(join(tempRoot, 'src/assets/root.txt'), 'root');

const resolved = await resolveAssets(
[
{ glob: 'sub/', input: 'src/assets', output: 'assets' },
{ glob: 'sub/', input: 'src/assets', output: 'assets', ignore: ['ignored.txt'] },
{ glob: 'sub', input: 'src/assets', output: 'assets' },
{ glob: 'sub', input: 'src/assets', output: 'assets', ignore: ['ignored.txt'] },
{ glob: 'root.txt/', input: 'src/assets', output: 'assets' },
{ glob: 'root.txt/', input: 'src/assets', output: 'assets', ignore: ['ignored.txt'] },
],
tempRoot,
);

expect(resolved).toHaveSize(0);
});

it('should expand directory patterns in ignore while keeping non-ignored files', async () => {
await mkdir(join(tempRoot, 'src/assets/sub-a/nested'), { recursive: true });
await mkdir(join(tempRoot, 'src/assets/sub-b/nested'), { recursive: true });
await writeFile(join(tempRoot, 'src/assets/sub-a/nested/a.txt'), 'a');
await writeFile(join(tempRoot, 'src/assets/sub-b/nested/b.txt'), 'b');
await writeFile(join(tempRoot, 'src/assets/keep.txt'), 'keep');

const resolved = await resolveAssets(
[{ glob: '**/*', input: 'src/assets', output: 'assets', ignore: ['sub-a/', 'sub-b'] }],
tempRoot,
);

expect(resolved).toEqual([
{
source: join(tempRoot, 'src/assets/keep.txt'),
destination: join('assets', 'keep.txt'),
},
]);
});

it('should ignore default ignored files even when specified as a static file glob without ignore', async () => {
await mkdir(join(tempRoot, 'src/assets'), { recursive: true });
await writeFile(join(tempRoot, 'src/assets/.gitkeep'), '');
await writeFile(join(tempRoot, 'src/assets/.DS_Store'), '');
await writeFile(join(tempRoot, 'src/assets/Thumbs.db'), '');

const resolved = await resolveAssets(
[
{ glob: '.gitkeep', input: 'src/assets', output: 'assets' },
{ glob: '.DS_Store', input: 'src/assets', output: 'assets' },
{ glob: 'Thumbs.db', input: 'src/assets', output: 'assets' },
],
tempRoot,
);

expect(resolved).toHaveSize(0);
});
});
75 changes: 68 additions & 7 deletions packages/angular/build/src/utils/resolve-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,64 @@ import { glob, isDynamicPattern } from 'tinyglobby';
import { MAX_CONCURRENT_READS, mapConcurrent } from './concurrency';
import { isSubDirectory } from './path';

const DEFAULT_ASSET_IGNORE_FILES: ReadonlySet<string> = new Set([
'.gitkeep',
'.DS_Store',
'Thumbs.db',
]);
const DEFAULT_ASSET_IGNORE_PATTERNS: readonly string[] = Array.from(
DEFAULT_ASSET_IGNORE_FILES,
(file) => `**/${file}`,
);

/**
* Normalizes an ignore glob pattern to match directory-exclusion semantics by stripping
* trailing slashes, normalizing POSIX segments, and expanding patterns with `/**`
* (returning both `normalized` and `${normalized}/**` for single-wildcard suffixes
* because `picomatch` does not make trailing `/**` optional after `*`).
*
* @param pattern The raw ignore glob pattern string.
* @returns The normalized POSIX glob patterns.
*/
function normalizeIgnorePattern(pattern: string): string[] {
if (!pattern) {
return [];
}

const posixNormalized = path.posix.normalize(pattern);
const normalized =
posixNormalized.length > 1 && posixNormalized.endsWith('/')
? posixNormalized.slice(0, -1)
: posixNormalized;

if (!normalized.endsWith('*')) {
return [`${normalized}/**`];
}

if (normalized === '**' || normalized.endsWith('/**')) {
return [normalized];
}

return [normalized, `${normalized}/**`];
}

/**
* Default glob ignore patterns for assets.
* Combines default asset ignore patterns with user-provided ignore patterns and normalizes
* them so directory patterns (such as `'sub'` or `'sub/'`) exclude all nested files.
*
* @param ignore Optional user-provided ignore glob patterns.
* @returns The normalized ignore glob patterns.
*/
export const DEFAULT_ASSET_IGNORE = ['**/.gitkeep', '**/.DS_Store', '**/Thumbs.db'] as const;
export function normalizeAssetIgnorePatterns(ignore?: readonly string[]): string[] {
if (!ignore?.length) {
return [...DEFAULT_ASSET_IGNORE_PATTERNS];
}
Comment thread
alan-agius4 marked this conversation as resolved.

return [
...DEFAULT_ASSET_IGNORE_PATTERNS,
...ignore.flatMap((pattern) => normalizeIgnorePattern(pattern)),
];
}

export async function resolveAssets(
entries: {
Expand All @@ -33,22 +87,28 @@ export async function resolveAssets(
throw new Error(`The ${entry.input} asset path must be within the workspace root.`);
}

if (!entry.glob || entry.glob.endsWith('/')) {
return [];
}

const cwd = path.resolve(root, entry.input);

if (!entry.ignore?.length && !isDynamicPattern(entry.glob)) {
const src = path.join(cwd, entry.glob);
if (isSubDirectory(cwd, src)) {
if (DEFAULT_ASSET_IGNORE_FILES.has(path.basename(src))) {
return [];
}

try {
const stats = await (entry.followSymlinks ? stat(src) : lstat(src));
if (stats.isFile()) {
const filePath = entry.flatten ? path.basename(entry.glob) : entry.glob;
const filePath = entry.flatten ? path.basename(src) : path.relative(cwd, src);

return [{ source: src, destination: path.join(entry.output, filePath) }];
}

if (!stats.isSymbolicLink()) {
return [];
}
return [];
} catch {
// File does not exist or cannot be accessed.
return [];
Expand All @@ -59,7 +119,8 @@ export async function resolveAssets(
const files = await glob(entry.glob, {
cwd,
dot: true,
ignore: entry.ignore ? [...DEFAULT_ASSET_IGNORE, ...entry.ignore] : DEFAULT_ASSET_IGNORE,
expandDirectories: false,
ignore: normalizeAssetIgnorePatterns(entry.ignore),
followSymbolicLinks: entry.followSymlinks ?? false,
});

Expand Down
Loading