mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
fix(ui): replace icon ligatures missing from the font and guard them (#1864)
* fix(ui): replace icon ligatures missing from the font and guard them The download and recording queues showed the literal text "file_off" for a missing file: the bundled Material Icons font (material-design-icons-iconfont 6.7.0) has no such ligature, so it rendered as text overflowing the icon box. Use error_outline, which the missing state's tertiary palette keeps distinct from the filled error glyph of a failed download. Add `pnpm run styles:icon-ligatures:validate` (CI): it parses templates with @angular/compiler and TypeScript with the compiler API, collects static <mat-icon> text, the string results of its bindings, icon/*Icon inputs and icon-named TypeScript values, and fails on a name missing from the font's codepoints file. Two listed codepoints the font has no ligature for (rounded_corner, stairs) are excluded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tools): check quoted inline templates in the icon guard Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tools): accept every codepoint and report escaped template lines The first measurement rendered the names without the package's CSS. With material-design-icons.css, all 2,193 codepoint names, rounded_corner and stairs included, draw as one glyph, so the guard accepts the whole file. Inline templates now map each cooked position back to the source through escapes, line continuations and CRLF, so a name after `\n` in a quoted template is reported on the line it is written on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8fabb88106
commit
2dff91c9f2
14 files changed
+813
-4
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: fix
|
||||
area: downloads
|
||||
---
|
||||
|
||||
Downloads and recordings whose file is gone from disk show a warning icon next
|
||||
to "File missing" again, instead of the stray text "file_off".
|
||||
@@ -36,6 +36,9 @@ not UI or util.
|
||||
instead of rebuilding those views.
|
||||
- Shared changes require checking every affected M3U, Xtream, Stalker,
|
||||
workspace, and collection consumer in light and dark themes.
|
||||
- `<mat-icon>` names must exist in the bundled Material Icons font;
|
||||
`pnpm run styles:icon-ligatures:validate` checks them (UI guidelines, Icon
|
||||
Names).
|
||||
|
||||
## Validation
|
||||
|
||||
|
||||
@@ -409,6 +409,10 @@ jobs:
|
||||
- name: Validate Angular Material token overrides
|
||||
run: pnpm run styles:material-tokens:validate
|
||||
|
||||
# The icon font renders an unknown ligature name as plain text.
|
||||
- name: Validate Material icon names
|
||||
run: pnpm run styles:icon-ligatures:validate
|
||||
|
||||
# Nx rejects a non-parallel task with continuous dependencies, and
|
||||
# the Playwright plugin infers serve dependencies only when CI is
|
||||
# unset, so this checks both the local and the CI inference.
|
||||
|
||||
@@ -340,6 +340,17 @@ test.describe('Electron Downloads', () => {
|
||||
await expect(missingRow.getByRole('status')).toContainText(
|
||||
'File missing'
|
||||
);
|
||||
// A name missing from the icon font renders as text that
|
||||
// overflows the icon box instead of one glyph.
|
||||
const missingIcon = missingRow
|
||||
.getByRole('status')
|
||||
.locator('mat-icon');
|
||||
await expect(missingIcon).toHaveText('error_outline');
|
||||
expect(
|
||||
await missingIcon.evaluate(
|
||||
(icon) => icon.scrollWidth <= icon.clientWidth
|
||||
)
|
||||
).toBe(true);
|
||||
await expect(card).toHaveCount(0);
|
||||
await expect(
|
||||
missingRow.getByRole('button', { name: 'Play: E2E Movie' })
|
||||
|
||||
@@ -881,6 +881,18 @@ text) and `subject` (pasted text in the add flow). Use
|
||||
`getPlaylistSourceIcon()` for a stored playlist. An icon never stands for two
|
||||
providers, and the Dashboard rail icon is never a provider icon.
|
||||
|
||||
## Icon Names
|
||||
|
||||
`<mat-icon>` draws its text through the ligatures of the Material Icons font
|
||||
from `material-design-icons-iconfont`, imported in `apps/web/src/styles.scss`.
|
||||
The font renders a name it does not know as plain text, so pick names from its
|
||||
`dist/fonts/MaterialIcons-Regular.json` codepoints file: newer Material Symbols
|
||||
names such as `file_off` or `arrow_outward` are not in it.
|
||||
`pnpm run styles:icon-ligatures:validate` (CI) checks static `<mat-icon>` text,
|
||||
the string results of its bindings, `icon` / `*Icon` inputs, and TypeScript
|
||||
values named `icon`, `*Icon`, `*_ICON`, `*Icons` or `*_ICONS`. Name a member
|
||||
that feeds an icon that way, so the check can see its values.
|
||||
|
||||
## Phone Layout
|
||||
|
||||
`640px` is the phone breakpoint. Use `@media (max-width: 640px)` rather than
|
||||
|
||||
@@ -238,7 +238,7 @@ describe('DownloadQueueComponent', () => {
|
||||
).map((button) => button.dataset['testAction']);
|
||||
expect(status?.textContent).toContain('File missing');
|
||||
expect(status?.querySelector('mat-icon')?.textContent?.trim()).toBe(
|
||||
'file_off'
|
||||
'error_outline'
|
||||
);
|
||||
expect(renderedActions).toEqual(['redownload']);
|
||||
expect(host.querySelector('[data-test-action="play"]')).toBeNull();
|
||||
|
||||
@@ -152,7 +152,7 @@ export class DownloadQueueComponent {
|
||||
|
||||
statusIcon(row: DownloadListItemViewModel): string {
|
||||
return this.isMissingFile(row)
|
||||
? 'file_off'
|
||||
? 'error_outline'
|
||||
: STATUS_ICONS[row.item.status];
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
{{
|
||||
row.attentionReason ===
|
||||
'file-missing'
|
||||
? 'file_off'
|
||||
? 'error_outline'
|
||||
: 'error'
|
||||
}}
|
||||
</mat-icon>
|
||||
|
||||
@@ -215,7 +215,7 @@ describe('RecordingQueueComponent', () => {
|
||||
);
|
||||
expect(warnChip?.textContent).toContain('File missing');
|
||||
expect(warnChip?.querySelector('mat-icon')?.textContent?.trim()).toBe(
|
||||
'file_off'
|
||||
'error_outline'
|
||||
);
|
||||
expect(host.querySelector('.recording-queue__progress')).toBeNull();
|
||||
expect(
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
"styles:material-tokens:test": "node --test tools/nx/check-material-token-overrides.test.mjs",
|
||||
"styles:material-tokens:check": "node tools/nx/check-material-token-overrides.mjs",
|
||||
"styles:material-tokens:validate": "pnpm run styles:material-tokens:test && pnpm run styles:material-tokens:check",
|
||||
"styles:icon-ligatures:test": "node --test tools/nx/check-icon-ligatures.test.mjs",
|
||||
"styles:icon-ligatures:check": "node tools/nx/check-icon-ligatures.mjs",
|
||||
"styles:icon-ligatures:validate": "pnpm run styles:icon-ligatures:test && pnpm run styles:icon-ligatures:check",
|
||||
"e2e:task-graphs:test": "node --test tools/nx/check-e2e-task-graphs.test.mjs",
|
||||
"e2e:task-graphs:check": "node tools/nx/check-e2e-task-graphs.mjs",
|
||||
"e2e:task-graphs:validate": "pnpm run e2e:task-graphs:test && pnpm run e2e:task-graphs:check",
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
iconNamesInTemplate,
|
||||
iconNamesInTypeScript,
|
||||
} from './icon-ligature-sources.mjs';
|
||||
|
||||
/**
|
||||
* `<mat-icon>` draws its text through the Material Icons font's ligatures.
|
||||
* A name the font does not know renders as the literal text ("file_off"),
|
||||
* overflowing the 24px box, and nothing fails at build or test time. This
|
||||
* guard checks every statically visible icon name against the codepoints
|
||||
* file shipped with the font the app loads (`apps/web/src/styles.scss`).
|
||||
*/
|
||||
export const FONT_PACKAGE = 'material-design-icons-iconfont';
|
||||
const CODEPOINTS = `${FONT_PACKAGE}/dist/fonts/MaterialIcons-Regular.json`;
|
||||
|
||||
/** Renderer sources: the font only exists in the Angular app. */
|
||||
const SCANNED_PATHSPECS = [
|
||||
'apps/web/*.html',
|
||||
'apps/web/*.ts',
|
||||
'libs/*.html',
|
||||
'libs/*.ts',
|
||||
];
|
||||
|
||||
function isScanned(file) {
|
||||
return !/\.(spec|test)\.[cm]?[jt]s$/.test(file);
|
||||
}
|
||||
|
||||
/** Ligature names of the bundled font. */
|
||||
export async function loadLigatures() {
|
||||
const require = createRequire(import.meta.url);
|
||||
const codepoints = JSON.parse(
|
||||
await readFile(require.resolve(CODEPOINTS), 'utf8')
|
||||
);
|
||||
return new Set(Object.keys(codepoints));
|
||||
}
|
||||
|
||||
/** Icon names in one source file that the font cannot draw. */
|
||||
export function findUnknownIcons(file, source, ligatures) {
|
||||
const found = file.endsWith('.html')
|
||||
? iconNamesInTemplate(source, file)
|
||||
: iconNamesInTypeScript(source, file);
|
||||
return found
|
||||
.filter(({ name }) => looksLikeIconName(name))
|
||||
.filter(({ name }) => !ligatures.has(name))
|
||||
.map((entry) => ({ file, ...entry }));
|
||||
}
|
||||
|
||||
/**
|
||||
* An `icon` value can legitimately be an image URL or inline SVG markup; only
|
||||
* single words are meant as ligatures.
|
||||
*/
|
||||
function looksLikeIconName(name) {
|
||||
return /^[A-Za-z0-9_-]+$/.test(name);
|
||||
}
|
||||
|
||||
/** Tracked renderer files the guard reads, at any depth. */
|
||||
export function listScannedFiles(rootDir) {
|
||||
// No shell: `cmd.exe` treats single quotes as literal characters, so a
|
||||
// POSIX-quoted pathspec reaches git intact on Windows and matches nothing.
|
||||
return execFileSync('git', ['ls-files', ...SCANNED_PATHSPECS], {
|
||||
cwd: rootDir,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
})
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.filter(isScanned);
|
||||
}
|
||||
|
||||
const isMain =
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) ===
|
||||
path.resolve(fileURLToPath(import.meta.url));
|
||||
|
||||
if (isMain) {
|
||||
const rootDir = process.cwd();
|
||||
const ligatures = await loadLigatures();
|
||||
const files = listScannedFiles(rootDir);
|
||||
|
||||
const findings = [];
|
||||
for (const file of files) {
|
||||
const source = await readFile(path.join(rootDir, file), 'utf8');
|
||||
findings.push(...findUnknownIcons(file, source, ligatures));
|
||||
}
|
||||
|
||||
if (findings.length > 0) {
|
||||
console.error(
|
||||
`Icon names missing from the Material Icons font (${CODEPOINTS}). The font renders them as plain text; pick a listed name:`
|
||||
);
|
||||
for (const { file, line, name, source } of findings) {
|
||||
console.error(`- ${file}:${line} ${name} (${source})`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`Checked ${files.length} renderer files against ${ligatures.size} Material Icons ligatures; every static icon name exists.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
FONT_PACKAGE,
|
||||
findUnknownIcons,
|
||||
listScannedFiles,
|
||||
loadLigatures,
|
||||
} from './check-icon-ligatures.mjs';
|
||||
import { isIconName } from './icon-ligature-sources.mjs';
|
||||
|
||||
const ligatures = await loadLigatures();
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../..'
|
||||
);
|
||||
const unknown = (file, source) =>
|
||||
findUnknownIcons(file, source, ligatures).map(
|
||||
({ line, name, source: from }) => ({ line, name, source: from })
|
||||
);
|
||||
|
||||
test('reads the font the web app loads', () => {
|
||||
const styles = readFileSync(
|
||||
path.join(repoRoot, 'apps/web/src/styles.scss'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.match(styles, new RegExp(`@import '${FONT_PACKAGE}/`));
|
||||
assert.ok(ligatures.has('error_outline'));
|
||||
assert.ok(ligatures.has('open_in_new'));
|
||||
assert.ok(!ligatures.has('file_off'));
|
||||
assert.ok(!ligatures.has('arrow_outward'));
|
||||
});
|
||||
|
||||
test('accepts every name in the codepoints file', () => {
|
||||
// Rendered with the package CSS in Chromium, all 2,193 names draw as one
|
||||
// glyph, these two included.
|
||||
assert.equal(ligatures.size, 2193);
|
||||
assert.ok(ligatures.has('rounded_corner'));
|
||||
assert.ok(ligatures.has('stairs'));
|
||||
});
|
||||
|
||||
test('reports unknown static <mat-icon> text on the line of the name', () => {
|
||||
const template = [
|
||||
'<button>',
|
||||
' <mat-icon>play_arrow</mat-icon>',
|
||||
' <mat-icon aria-hidden="true">',
|
||||
' arrow_outward',
|
||||
' </mat-icon>',
|
||||
'</button>',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(unknown('a.html', template), [
|
||||
{ line: 4, name: 'arrow_outward', source: '<mat-icon> text' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('reads only the results of a <mat-icon> interpolation', () => {
|
||||
const template = [
|
||||
'<mat-icon>',
|
||||
" {{ reason === 'file-missing'",
|
||||
" ? 'file_off'",
|
||||
" : 'error' }}",
|
||||
'</mat-icon>',
|
||||
"<mat-icon>{{ name ?? 'no_such_icon' }}</mat-icon>",
|
||||
"<mat-icon>{{ 'mystery_' + kind }}</mat-icon>",
|
||||
'<mat-icon>prefix_{{ kind }}</mat-icon>',
|
||||
'<mat-icon>{{ statusIcon(row) }}</mat-icon>',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(unknown('b.html', template), [
|
||||
{ line: 3, name: 'file_off', source: '<mat-icon> binding' },
|
||||
{ line: 6, name: 'no_such_icon', source: '<mat-icon> binding' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('checks icon inputs but not classes, attributes or SVG icons', () => {
|
||||
const template = [
|
||||
'<app-empty-state icon="tv_offf" actionIcon="chevron_right" />',
|
||||
"<app-button [icon]=\"on ? 'favorite' : 'favourite_border'\" />",
|
||||
'<div [class.icon]="true" [attr.data-icon]="\'not_a_name\'"></div>',
|
||||
'<mat-icon svgIcon="custom_logo"></mat-icon>',
|
||||
'<app-logo icon="https://example.test/logo.png" />',
|
||||
"@if (show) { <mat-icon>{{ on ? 'lock' : 'unlockk' }}</mat-icon> }",
|
||||
'@for (x of xs; track x) { <app-row [auxActionIcon]="\'delete\'" /> }',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(unknown('c.html', template), [
|
||||
{ line: 1, name: 'tv_offf', source: 'icon attribute' },
|
||||
{ line: 2, name: 'favourite_border', source: '[icon] binding' },
|
||||
{ line: 6, name: 'unlockk', source: '<mat-icon> binding' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('reads icon-named TypeScript declarations and inline templates', () => {
|
||||
const source = `
|
||||
const STATUS_ICONS: Record<Status, string> = {
|
||||
queued: 'schedule',
|
||||
failed: 'error_bad',
|
||||
};
|
||||
const AUDIO_TRACK_ICON = '<svg viewBox="0 0 24 24"></svg>';
|
||||
const LABEL = 'not_an_icon_name';
|
||||
|
||||
@Component({
|
||||
selector: 'app-row',
|
||||
template: \`
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
<mat-icon>chevron_rightt</mat-icon>
|
||||
\`,
|
||||
})
|
||||
export class RowComponent {
|
||||
readonly icon = input('live_tvv');
|
||||
readonly sortIcon = computed(() =>
|
||||
this.ascending() ? 'arrow_upward' : 'arrow_downward_x'
|
||||
);
|
||||
readonly options = [{ value: 'a', icon: 'grid_view' }, { icon: 'listt' }];
|
||||
readonly emptyIcon = '';
|
||||
|
||||
statusIcon(row: Row): string {
|
||||
if (row.missing) {
|
||||
return 'file_off';
|
||||
}
|
||||
const label = () => 'nested_closure_result';
|
||||
return STATUS_ICONS[row.status] ?? \`x_\${row.kind}\`;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
assert.deepEqual(unknown('d.component.ts', source), [
|
||||
{ line: 4, name: 'error_bad', source: 'STATUS_ICONS value' },
|
||||
{ line: 13, name: 'chevron_rightt', source: '<mat-icon> text' },
|
||||
{ line: 17, name: 'live_tvv', source: 'icon value' },
|
||||
{ line: 19, name: 'arrow_downward_x', source: 'sortIcon value' },
|
||||
{ line: 21, name: 'listt', source: 'icon value' },
|
||||
{ line: 26, name: 'file_off', source: 'statusIcon value' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('reads quoted inline templates, but not other template properties', () => {
|
||||
const source = [
|
||||
"@Component({ selector: 'a', template: '<mat-icon>file_off</mat-icon>' })",
|
||||
'export class A {}',
|
||||
"@Component({ selector: 'b', template: \"<mat-icon>{{ on ? 'x' : 'unlockk' }}</mat-icon>\" })",
|
||||
'export class B {}',
|
||||
"const notAComponent = { template: '<mat-icon>not_checked</mat-icon>' };",
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(unknown('e.component.ts', source), [
|
||||
{ line: 1, name: 'file_off', source: '<mat-icon> text' },
|
||||
{ line: 3, name: 'x', source: '<mat-icon> binding' },
|
||||
{ line: 3, name: 'unlockk', source: '<mat-icon> binding' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('reports inline template lines through escapes and line continuations', () => {
|
||||
const source = [
|
||||
"@Component({ selector: 'a', template: '<b>a</b>\\n<mat-icon>bad_one</mat-icon>' })",
|
||||
'export class A {}',
|
||||
'@Component({',
|
||||
" selector: 'b',",
|
||||
' template: `<b>a</b>\\n<b>\\u{1F600}</b>',
|
||||
' <mat-icon>bad_two</mat-icon>`,',
|
||||
'})',
|
||||
"@Component({ selector: 'c', template: '<b>c</b>\\",
|
||||
"<mat-icon>bad_three</mat-icon>' })",
|
||||
"@Component({ selector: 'd', template: `<b>d</b>\r\n<mat-icon>bad_four</mat-icon>` })",
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(unknown('f.component.ts', source), [
|
||||
{ line: 1, name: 'bad_one', source: '<mat-icon> text' },
|
||||
{ line: 6, name: 'bad_two', source: '<mat-icon> text' },
|
||||
{ line: 9, name: 'bad_three', source: '<mat-icon> text' },
|
||||
{ line: 11, name: 'bad_four', source: '<mat-icon> text' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('names icon sources by convention, not SVG icon inputs', () => {
|
||||
for (const name of [
|
||||
'icon',
|
||||
'statusIcon',
|
||||
'STATUS_ICON',
|
||||
'sourceIcons',
|
||||
'STATUS_ICONS',
|
||||
]) {
|
||||
assert.ok(isIconName(name), name);
|
||||
}
|
||||
for (const name of ['svgIcon', 'Icon', 'icons', 'iconSize', 'lexicon']) {
|
||||
assert.ok(!isIconName(name), name);
|
||||
}
|
||||
});
|
||||
|
||||
test('selects tracked web and lib sources at any depth, but not specs', async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'icon-guard-'));
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: rootDir, stdio: 'pipe' });
|
||||
try {
|
||||
git('init', '-q');
|
||||
const files = {
|
||||
'libs/ui/feature/src/lib/deep/panel.component.html': '',
|
||||
'libs/ui/feature/src/lib/deep/panel.component.ts': '',
|
||||
'libs/ui/feature/src/lib/deep/panel.component.spec.ts': '',
|
||||
'apps/web/src/app/app.component.html': '',
|
||||
'apps/website/src/page.html': '',
|
||||
'apps/electron-backend/src/main.ts': '',
|
||||
'tools/outside.ts': '',
|
||||
};
|
||||
for (const [file, content] of Object.entries(files)) {
|
||||
await mkdir(path.dirname(path.join(rootDir, file)), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(path.join(rootDir, file), content);
|
||||
}
|
||||
git('add', '.');
|
||||
|
||||
assert.deepEqual(listScannedFiles(rootDir).sort(), [
|
||||
'apps/web/src/app/app.component.html',
|
||||
'libs/ui/feature/src/lib/deep/panel.component.html',
|
||||
'libs/ui/feature/src/lib/deep/panel.component.ts',
|
||||
]);
|
||||
} finally {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import {
|
||||
Binary,
|
||||
Conditional,
|
||||
Interpolation,
|
||||
LiteralPrimitive,
|
||||
NonNullAssert,
|
||||
ParenthesizedExpression,
|
||||
parseTemplate,
|
||||
TmplAstBoundAttribute,
|
||||
TmplAstBoundText,
|
||||
TmplAstRecursiveVisitor,
|
||||
TmplAstText,
|
||||
TmplAstTextAttribute,
|
||||
tmplAstVisitAll,
|
||||
} from '@angular/compiler';
|
||||
import ts from 'typescript';
|
||||
import { literalLineResolver } from './literal-source-lines.mjs';
|
||||
|
||||
/**
|
||||
* Collects the icon names a source can hand to `<mat-icon>`, where they are
|
||||
* statically visible:
|
||||
*
|
||||
* - the static text of a `<mat-icon>` and the string literals its
|
||||
* interpolations can evaluate to (`{{ a ? 'lock' : 'lock_open' }}`);
|
||||
* - values given to an `icon` / `*Icon` input (`icon="live_tv"`,
|
||||
* `[icon]="on ? 'star' : 'star_outline'"`);
|
||||
* - TypeScript members, properties, constants and parameters named `icon`,
|
||||
* `*Icon`, `*_ICON`, or maps named `*Icons` / `*_ICONS`, which feed bindings
|
||||
* such as `{{ option.icon }}` and `{{ statusIcon(row) }}`.
|
||||
*
|
||||
* A value built at runtime (`'x_' + y`, a service result) is not resolvable
|
||||
* and is skipped. Only string literals in result position count, so the
|
||||
* `'file-missing'` in `reason === 'file-missing' ? 'error' : 'info'` is not
|
||||
* read as an icon.
|
||||
*/
|
||||
|
||||
const ICON_NAME = /^(icon|ICON|[a-z][A-Za-z0-9]*Icon|[A-Z][A-Z0-9_]*_ICON)$/;
|
||||
const ICON_MAP_NAME = /^(ICONS|[a-z][A-Za-z0-9]*Icons|[A-Z][A-Z0-9_]*_ICONS)$/;
|
||||
/** Registered SVG names, not font ligatures. */
|
||||
const NON_LIGATURE_INPUTS = new Set(['svgIcon']);
|
||||
/** Signal factories whose first argument is the value they hold. */
|
||||
const SIGNAL_FACTORIES = new Set([
|
||||
'computed',
|
||||
'input',
|
||||
'linkedSignal',
|
||||
'model',
|
||||
'signal',
|
||||
]);
|
||||
/** Angular `BindingType.Property` and `BindingType.TwoWay`. */
|
||||
const INPUT_BINDING_TYPES = new Set([0, 5]);
|
||||
|
||||
export function isIconName(name) {
|
||||
return (
|
||||
(ICON_NAME.test(name) || ICON_MAP_NAME.test(name)) &&
|
||||
!NON_LIGATURE_INPUTS.has(name)
|
||||
);
|
||||
}
|
||||
|
||||
/** String literal nodes a template expression can evaluate to. */
|
||||
function templateResults(ast, out = []) {
|
||||
if (ast instanceof LiteralPrimitive) {
|
||||
if (typeof ast.value === 'string') {
|
||||
out.push(ast);
|
||||
}
|
||||
} else if (ast instanceof Conditional) {
|
||||
templateResults(ast.trueExp, out);
|
||||
templateResults(ast.falseExp, out);
|
||||
} else if (ast instanceof Binary) {
|
||||
if (ast.operation === '??' || ast.operation === '||') {
|
||||
templateResults(ast.left, out);
|
||||
}
|
||||
if (['??', '||', '&&'].includes(ast.operation)) {
|
||||
templateResults(ast.right, out);
|
||||
}
|
||||
} else if (
|
||||
ast instanceof ParenthesizedExpression ||
|
||||
ast instanceof NonNullAssert
|
||||
) {
|
||||
templateResults(ast.expression, out);
|
||||
} else if (ast instanceof Interpolation) {
|
||||
// `prefix_{{ name }}` builds a name at runtime: not resolvable.
|
||||
if (ast.strings.every((part) => part.trim() === '')) {
|
||||
for (const expression of ast.expressions) {
|
||||
templateResults(expression, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
class TemplateIconVisitor extends TmplAstRecursiveVisitor {
|
||||
constructor(lineAt) {
|
||||
super();
|
||||
this.lineAt = lineAt;
|
||||
this.found = [];
|
||||
}
|
||||
|
||||
/** A static value, reported on the line its text starts. */
|
||||
addText(name, span, source) {
|
||||
const raw = span.toString();
|
||||
const leading = raw.length - raw.trimStart().length;
|
||||
this.add(name, this.lineAt(span.start.offset + leading), source);
|
||||
}
|
||||
|
||||
/** Literals from a binding, each reported on its own line. */
|
||||
addResults(ast, source) {
|
||||
for (const literal of templateResults(ast)) {
|
||||
this.add(
|
||||
literal.value,
|
||||
this.lineAt(literal.sourceSpan.start),
|
||||
source
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
add(name, line, source) {
|
||||
this.found.push({ name, line: line + 1, source });
|
||||
}
|
||||
|
||||
visitElement(element) {
|
||||
for (const attribute of element.attributes) {
|
||||
if (
|
||||
attribute instanceof TmplAstTextAttribute &&
|
||||
isIconName(attribute.name) &&
|
||||
attribute.value.trim() !== ''
|
||||
) {
|
||||
this.addText(
|
||||
attribute.value.trim(),
|
||||
attribute.valueSpan ?? attribute.sourceSpan,
|
||||
`${attribute.name} attribute`
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const input of element.inputs) {
|
||||
if (
|
||||
input instanceof TmplAstBoundAttribute &&
|
||||
INPUT_BINDING_TYPES.has(input.type) &&
|
||||
isIconName(input.name)
|
||||
) {
|
||||
this.addResults(input.value.ast, `[${input.name}] binding`);
|
||||
}
|
||||
}
|
||||
const isSvgIcon = element.attributes.some(
|
||||
(attribute) => attribute.name === 'svgIcon'
|
||||
);
|
||||
if (element.name === 'mat-icon' && !isSvgIcon) {
|
||||
for (const child of element.children) {
|
||||
if (child instanceof TmplAstText && child.value.trim()) {
|
||||
this.addText(
|
||||
child.value.trim(),
|
||||
child.sourceSpan,
|
||||
'<mat-icon> text'
|
||||
);
|
||||
} else if (child instanceof TmplAstBoundText) {
|
||||
this.addResults(child.value.ast, '<mat-icon> binding');
|
||||
}
|
||||
}
|
||||
}
|
||||
super.visitElement(element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon names in an Angular template. `lineAt` maps a template offset to its
|
||||
* zero-based source line; an inline template passes its literal's mapping.
|
||||
*/
|
||||
export function iconNamesInTemplate(
|
||||
template,
|
||||
file,
|
||||
lineAt = (offset) => template.slice(0, offset).split('\n').length - 1
|
||||
) {
|
||||
// Keep whitespace so text spans and values match the source lines.
|
||||
const parsed = parseTemplate(template, file, {
|
||||
preserveWhitespaces: true,
|
||||
});
|
||||
if (parsed.errors?.length) {
|
||||
throw new Error(
|
||||
`${file}: cannot parse template: ${parsed.errors[0].msg}`
|
||||
);
|
||||
}
|
||||
const visitor = new TemplateIconVisitor(lineAt);
|
||||
tmplAstVisitAll(visitor, parsed.nodes);
|
||||
return visitor.found;
|
||||
}
|
||||
|
||||
function unwrap(node) {
|
||||
while (
|
||||
ts.isParenthesizedExpression(node) ||
|
||||
ts.isAsExpression(node) ||
|
||||
ts.isSatisfiesExpression(node) ||
|
||||
ts.isNonNullExpression(node) ||
|
||||
ts.isTypeAssertionExpression(node)
|
||||
) {
|
||||
node = node.expression;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Return statements of a function body, not of functions nested in it. */
|
||||
function returnedExpressions(body, out = []) {
|
||||
ts.forEachChild(body, (child) => {
|
||||
if (ts.isReturnStatement(child)) {
|
||||
if (child.expression) {
|
||||
out.push(child.expression);
|
||||
}
|
||||
} else if (!ts.isFunctionLike(child) && !ts.isClassLike(child)) {
|
||||
returnedExpressions(child, out);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** String literal nodes a TypeScript expression can evaluate to. */
|
||||
function typeScriptResults(expression, out = []) {
|
||||
const node = unwrap(expression);
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||
out.push(node);
|
||||
} else if (ts.isConditionalExpression(node)) {
|
||||
typeScriptResults(node.whenTrue, out);
|
||||
typeScriptResults(node.whenFalse, out);
|
||||
} else if (ts.isBinaryExpression(node)) {
|
||||
const operator = node.operatorToken.kind;
|
||||
if (
|
||||
operator === ts.SyntaxKind.QuestionQuestionToken ||
|
||||
operator === ts.SyntaxKind.BarBarToken
|
||||
) {
|
||||
typeScriptResults(node.left, out);
|
||||
typeScriptResults(node.right, out);
|
||||
} else if (operator === ts.SyntaxKind.AmpersandAmpersandToken) {
|
||||
typeScriptResults(node.right, out);
|
||||
}
|
||||
} else if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
|
||||
functionResults(node.body, out);
|
||||
} else if (ts.isCallExpression(node)) {
|
||||
const callee = ts.isPropertyAccessExpression(node.expression)
|
||||
? node.expression.expression
|
||||
: node.expression;
|
||||
if (
|
||||
ts.isIdentifier(callee) &&
|
||||
SIGNAL_FACTORIES.has(callee.text) &&
|
||||
node.arguments.length > 0
|
||||
) {
|
||||
typeScriptResults(node.arguments[0], out);
|
||||
}
|
||||
} else if (ts.isObjectLiteralExpression(node)) {
|
||||
for (const property of node.properties) {
|
||||
if (ts.isPropertyAssignment(property)) {
|
||||
typeScriptResults(property.initializer, out);
|
||||
}
|
||||
}
|
||||
} else if (ts.isArrayLiteralExpression(node)) {
|
||||
for (const element of node.elements) {
|
||||
typeScriptResults(element, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function functionResults(body, out) {
|
||||
if (!body) {
|
||||
return out;
|
||||
}
|
||||
if (!ts.isBlock(body)) {
|
||||
return typeScriptResults(body, out);
|
||||
}
|
||||
for (const expression of returnedExpressions(body)) {
|
||||
typeScriptResults(expression, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function declarationName(node) {
|
||||
const name = node.name;
|
||||
if (name && (ts.isIdentifier(name) || ts.isStringLiteral(name))) {
|
||||
return name.text;
|
||||
}
|
||||
if (name && ts.isPrivateIdentifier(name)) {
|
||||
return name.text.slice(1);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Literal nodes behind one icon-named declaration. */
|
||||
function declarationResults(node) {
|
||||
if (
|
||||
ts.isPropertyAssignment(node) ||
|
||||
ts.isPropertyDeclaration(node) ||
|
||||
ts.isVariableDeclaration(node) ||
|
||||
ts.isParameter(node)
|
||||
) {
|
||||
return node.initializer ? typeScriptResults(node.initializer) : [];
|
||||
}
|
||||
if (
|
||||
ts.isMethodDeclaration(node) ||
|
||||
ts.isGetAccessorDeclaration(node) ||
|
||||
ts.isFunctionDeclaration(node)
|
||||
) {
|
||||
return functionResults(node.body, []);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* `template` of an `@Component({...})` decorator, quoted or backticked. A
|
||||
* template with `${...}` substitutions is built at runtime and skipped.
|
||||
*/
|
||||
function isInlineTemplate(node) {
|
||||
const call = node.parent?.parent;
|
||||
return (
|
||||
ts.isPropertyAssignment(node) &&
|
||||
declarationName(node) === 'template' &&
|
||||
(ts.isNoSubstitutionTemplateLiteral(node.initializer) ||
|
||||
ts.isStringLiteral(node.initializer)) &&
|
||||
call !== undefined &&
|
||||
ts.isCallExpression(call) &&
|
||||
ts.isIdentifier(call.expression) &&
|
||||
call.expression.text === 'Component' &&
|
||||
ts.isDecorator(call.parent)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon names in a TypeScript source, plus those in its inline component
|
||||
* templates.
|
||||
*/
|
||||
export function iconNamesInTypeScript(source, file) {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
file,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true
|
||||
);
|
||||
const found = [];
|
||||
const lineOf = (node) =>
|
||||
sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
||||
.line;
|
||||
const visit = (node) => {
|
||||
const name = declarationName(node);
|
||||
if (name !== undefined && isIconName(name)) {
|
||||
for (const literal of declarationResults(node)) {
|
||||
found.push({
|
||||
name: literal.text,
|
||||
line: lineOf(literal) + 1,
|
||||
source: `${name} value`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (isInlineTemplate(node)) {
|
||||
found.push(
|
||||
...iconNamesInTemplate(
|
||||
node.initializer.text,
|
||||
file,
|
||||
literalLineResolver(node.initializer, sourceFile)
|
||||
)
|
||||
);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sourceFile);
|
||||
return found;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Maps a position in a string or template literal's cooked text (what
|
||||
* `literal.text` returns) to its line in the source file. Escapes such as
|
||||
* `\n` or `\u{…}`, line continuations and CRLF make the cooked text differ
|
||||
* from the source, so counting the cooked newlines would point at the wrong
|
||||
* line, possibly outside the component.
|
||||
*/
|
||||
export function literalLineResolver(literal, sourceFile) {
|
||||
const contentStart = literal.getStart(sourceFile) + 1;
|
||||
const raw = sourceFile.text.slice(contentStart, literal.getEnd() - 1);
|
||||
const rawOffsets = cookedToRawOffsets(raw);
|
||||
const lineAt = (position) =>
|
||||
sourceFile.getLineAndCharacterOfPosition(position).line;
|
||||
|
||||
if (rawOffsets.length !== literal.text.length + 1) {
|
||||
// An escape this mapping does not model: keep to the first line.
|
||||
const startLine = lineAt(contentStart);
|
||||
return () => startLine;
|
||||
}
|
||||
return (offset) =>
|
||||
lineAt(
|
||||
contentStart +
|
||||
rawOffsets[Math.min(Math.max(offset, 0), rawOffsets.length - 1)]
|
||||
);
|
||||
}
|
||||
|
||||
/** Offset in `raw` of each cooked UTF-16 unit, plus the end offset. */
|
||||
function cookedToRawOffsets(raw) {
|
||||
const offsets = [];
|
||||
let index = 0;
|
||||
while (index < raw.length) {
|
||||
if (raw[index] === '\r' && raw[index + 1] === '\n') {
|
||||
// A template literal cooks CRLF to one LF.
|
||||
offsets.push(index);
|
||||
index += 2;
|
||||
} else if (raw[index] !== '\\') {
|
||||
offsets.push(index);
|
||||
index += 1;
|
||||
} else {
|
||||
const { length, units } = escapeAt(raw, index);
|
||||
for (let unit = 0; unit < units; unit += 1) {
|
||||
offsets.push(index);
|
||||
}
|
||||
index += length;
|
||||
}
|
||||
}
|
||||
offsets.push(raw.length);
|
||||
return offsets;
|
||||
}
|
||||
|
||||
/** Source length and cooked UTF-16 length of the escape at `index`. */
|
||||
function escapeAt(raw, index) {
|
||||
const next = raw[index + 1];
|
||||
if (next === '\r') {
|
||||
// Line continuation: nothing reaches the cooked text.
|
||||
return { length: raw[index + 2] === '\n' ? 3 : 2, units: 0 };
|
||||
}
|
||||
if (next === '\n' || next === '
' || next === '
') {
|
||||
return { length: 2, units: 0 };
|
||||
}
|
||||
if (next === 'x') {
|
||||
return { length: 4, units: 1 };
|
||||
}
|
||||
if (next === 'u' && raw[index + 2] === '{') {
|
||||
const close = raw.indexOf('}', index);
|
||||
const codePoint = parseInt(raw.slice(index + 3, close), 16);
|
||||
return { length: close - index + 1, units: codePoint > 0xffff ? 2 : 1 };
|
||||
}
|
||||
if (next === 'u') {
|
||||
return { length: 6, units: 1 };
|
||||
}
|
||||
return { length: 2, units: 1 };
|
||||
}
|
||||
Reference in new issue
Block a user