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 }; +}