From 2dff91c9f275c3e110e64f1a363a714817bf1645 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:31:21 +0200 Subject: [PATCH] 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 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 * fix(tools): check quoted inline templates in the icon guard Co-Authored-By: Claude Opus 5.5 * 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 --------- Co-authored-by: Claude Opus 5.5 --- .changes/downloads-missing-file-icon.md | 7 + .codex/skills/iptvnator-ui-design/SKILL.md | 3 + .github/workflows/ci.yml | 4 + .../electron-backend-e2e/src/downloads.e2e.ts | 11 + docs/architecture/iptvnator-ui-guidelines.md | 12 + .../src/lib/download-queue.component.spec.ts | 2 +- .../src/lib/download-queue.component.ts | 2 +- .../src/lib/recording-queue.component.html | 2 +- .../src/lib/recording-queue.component.spec.ts | 2 +- package.json | 3 + tools/nx/check-icon-ligatures.mjs | 106 +++++ tools/nx/check-icon-ligatures.test.mjs | 229 +++++++++++ tools/nx/icon-ligature-sources.mjs | 361 ++++++++++++++++++ tools/nx/literal-source-lines.mjs | 73 ++++ 14 files changed, 813 insertions(+), 4 deletions(-) create mode 100644 .changes/downloads-missing-file-icon.md create mode 100644 tools/nx/check-icon-ligatures.mjs create mode 100644 tools/nx/check-icon-ligatures.test.mjs create mode 100644 tools/nx/icon-ligature-sources.mjs create mode 100644 tools/nx/literal-source-lines.mjs diff --git a/.changes/downloads-missing-file-icon.md b/.changes/downloads-missing-file-icon.md new file mode 100644 index 000000000..9556e704a --- /dev/null +++ b/.changes/downloads-missing-file-icon.md @@ -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". diff --git a/.codex/skills/iptvnator-ui-design/SKILL.md b/.codex/skills/iptvnator-ui-design/SKILL.md index 3554b796d..69543a392 100644 --- a/.codex/skills/iptvnator-ui-design/SKILL.md +++ b/.codex/skills/iptvnator-ui-design/SKILL.md @@ -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. +- `` names must exist in the bundled Material Icons font; + `pnpm run styles:icon-ligatures:validate` checks them (UI guidelines, Icon + Names). ## Validation diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a8b8cf4c..991cbc276 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. diff --git a/apps/electron-backend-e2e/src/downloads.e2e.ts b/apps/electron-backend-e2e/src/downloads.e2e.ts index b8b42a983..7247550bf 100644 --- a/apps/electron-backend-e2e/src/downloads.e2e.ts +++ b/apps/electron-backend-e2e/src/downloads.e2e.ts @@ -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' }) diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 292ddbc6e..39cd04a35 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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 + +`` 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 `` 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 diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts b/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts index af966dec6..8daaa1ffc 100644 --- a/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts @@ -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(); diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.ts b/libs/portal/downloads/feature/src/lib/download-queue.component.ts index 66b7f7d8e..78f131ed3 100644 --- a/libs/portal/downloads/feature/src/lib/download-queue.component.ts +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.ts @@ -152,7 +152,7 @@ export class DownloadQueueComponent { statusIcon(row: DownloadListItemViewModel): string { return this.isMissingFile(row) - ? 'file_off' + ? 'error_outline' : STATUS_ICONS[row.item.status]; } diff --git a/libs/portal/downloads/feature/src/lib/recording-queue.component.html b/libs/portal/downloads/feature/src/lib/recording-queue.component.html index d5e613fc8..69903c10e 100644 --- a/libs/portal/downloads/feature/src/lib/recording-queue.component.html +++ b/libs/portal/downloads/feature/src/lib/recording-queue.component.html @@ -99,7 +99,7 @@ {{ row.attentionReason === 'file-missing' - ? 'file_off' + ? 'error_outline' : 'error' }} diff --git a/libs/portal/downloads/feature/src/lib/recording-queue.component.spec.ts b/libs/portal/downloads/feature/src/lib/recording-queue.component.spec.ts index 92e04f3fe..2c91395f7 100644 --- a/libs/portal/downloads/feature/src/lib/recording-queue.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/recording-queue.component.spec.ts @@ -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( diff --git a/package.json b/package.json index 710f87f85..5fa6a78b6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tools/nx/check-icon-ligatures.mjs b/tools/nx/check-icon-ligatures.mjs new file mode 100644 index 000000000..e8f6ae4ff --- /dev/null +++ b/tools/nx/check-icon-ligatures.mjs @@ -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'; + +/** + * `` 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.` + ); + } +} diff --git a/tools/nx/check-icon-ligatures.test.mjs b/tools/nx/check-icon-ligatures.test.mjs new file mode 100644 index 000000000..d5a7e1d48 --- /dev/null +++ b/tools/nx/check-icon-ligatures.test.mjs @@ -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 text on the line of the name', () => { + const template = [ + '', + ].join('\n'); + + assert.deepEqual(unknown('a.html', template), [ + { line: 4, name: 'arrow_outward', source: ' text' }, + ]); +}); + +test('reads only the results of a interpolation', () => { + const template = [ + '', + " {{ reason === 'file-missing'", + " ? 'file_off'", + " : 'error' }}", + '', + "{{ name ?? 'no_such_icon' }}", + "{{ 'mystery_' + kind }}", + 'prefix_{{ kind }}', + '{{ statusIcon(row) }}', + ].join('\n'); + + assert.deepEqual(unknown('b.html', template), [ + { line: 3, name: 'file_off', source: ' binding' }, + { line: 6, name: 'no_such_icon', source: ' binding' }, + ]); +}); + +test('checks icon inputs but not classes, attributes or SVG icons', () => { + const template = [ + '', + "", + '
', + '', + '', + "@if (show) { {{ on ? 'lock' : 'unlockk' }} }", + '@for (x of xs; track x) { }', + ].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: ' binding' }, + ]); +}); + +test('reads icon-named TypeScript declarations and inline templates', () => { + const source = ` +const STATUS_ICONS: Record = { + queued: 'schedule', + failed: 'error_bad', +}; +const AUDIO_TRACK_ICON = ''; +const LABEL = 'not_an_icon_name'; + +@Component({ + selector: 'app-row', + template: \` + chevron_right + chevron_rightt + \`, +}) +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: ' 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: 'file_off' })", + 'export class A {}', + "@Component({ selector: 'b', template: \"{{ on ? 'x' : 'unlockk' }}\" })", + 'export class B {}', + "const notAComponent = { template: 'not_checked' };", + ].join('\n'); + + assert.deepEqual(unknown('e.component.ts', source), [ + { line: 1, name: 'file_off', source: ' text' }, + { line: 3, name: 'x', source: ' binding' }, + { line: 3, name: 'unlockk', source: ' binding' }, + ]); +}); + +test('reports inline template lines through escapes and line continuations', () => { + const source = [ + "@Component({ selector: 'a', template: 'a\\nbad_one' })", + 'export class A {}', + '@Component({', + " selector: 'b',", + ' template: `a\\n\\u{1F600}', + ' bad_two`,', + '})', + "@Component({ selector: 'c', template: 'c\\", + "bad_three' })", + "@Component({ selector: 'd', template: `d\r\nbad_four` })", + ].join('\n'); + + assert.deepEqual(unknown('f.component.ts', source), [ + { line: 1, name: 'bad_one', source: ' text' }, + { line: 6, name: 'bad_two', source: ' text' }, + { line: 9, name: 'bad_three', source: ' text' }, + { line: 11, name: 'bad_four', source: ' 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 }); + } +}); diff --git a/tools/nx/icon-ligature-sources.mjs b/tools/nx/icon-ligature-sources.mjs new file mode 100644 index 000000000..01f952fa5 --- /dev/null +++ b/tools/nx/icon-ligature-sources.mjs @@ -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 ``, where they are + * statically visible: + * + * - the static text of a `` 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, + ' text' + ); + } else if (child instanceof TmplAstBoundText) { + this.addResults(child.value.ast, ' 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; +} diff --git a/tools/nx/literal-source-lines.mjs b/tools/nx/literal-source-lines.mjs new file mode 100644 index 000000000..8e26c9958 --- /dev/null +++ b/tools/nx/literal-source-lines.mjs @@ -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 }; +}