Files
iptvnator/tools/nx/check-form-field-defaults.mjs
T
4grayandClaude Fable 5.1 ced7386dfa perf(web): render the radio play control as an icon button, not a fab (#1938)
The audio player's play/pause control was the app's only mat-fab. It is now
a mat-icon-button drawn as the same 56px primary circle (icon-button
overrides: 56px state layer, 32px white icon, 50% shape, selection-colour
fill, hover scale), so the eager button chunk no longer carries the fab
directives and the theme no longer emits the fab tokens:
renderer.initialBytes 1,349,048 -> 1,329,249 bytes (-19,799: -13,799 in
button.mjs, -6,028 in the stylesheet), measured locally against the merge
base. The fab also reset the keyboard focus ring with `outline: none`; the
icon button keeps the app ring.

- tools/nx/check-material-theme-scope.mjs: `button` brings button and
  icon-button; `fab` is required only while a runtime template renders
  mat-fab / mat-mini-fab (matFab / matMiniFab), found through the component
  templates the way check-form-field-defaults.mjs resolves them. That
  resolution moves to tools/nx/component-templates.mjs, shared by both.
  Templates know HTML comments only, so the scan strips just those: a `//`
  in text or an attribute value no longer hides a fab on the same line.
- apps/web/src/m3-theme.scss drops `fab` from $material-components.
- apps/web-e2e: the Stalker radio test checks the control's size, shape,
  fill, icon and keyboard focus ring.
- journey-baselines.json: renderer.initialBytes 1,345,807 -> 1,329,249.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 19:51:34 +02:00

155 lines
5.2 KiB
JavaScript

import { execFileSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
import {
componentMetadata,
componentTemplate,
property,
} from './component-templates.mjs';
/**
* The app's `<mat-form-field>` defaults (outline, dynamic subscript) are
* provided per component, not in the root injector: importing
* MAT_FORM_FIELD_DEFAULT_OPTIONS in app.config.ts loads
* `@angular/material/form-field` and, since Material 22.2, `@angular/forms`
* on startup, although every form field lives in a lazy chunk. A component
* that renders a form field without the provider silently falls back to the
* `fill` appearance, so this guard requires it on each such component, and
* allows the provider and the token nowhere else.
*/
export const DEFAULTS_FILE =
'libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts';
const TOKEN = 'MAT_FORM_FIELD_DEFAULT_OPTIONS';
const PROVIDER = 'provideFormFieldDefaults';
const SCANNED_PATHSPECS = ['apps/web/*.ts', 'libs/*.ts'];
function isScanned(file) {
return !/\.(spec|test)\.[cm]?[jt]s$/.test(file);
}
function isProviderCall(node) {
return (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === PROVIDER
);
}
/**
* Findings for one TypeScript source. `readTemplate` resolves a
* `templateUrl` relative to the component and returns its text.
*/
export async function checkComponentSource(file, source, readTemplate) {
const sourceFile = ts.createSourceFile(
file,
source,
ts.ScriptTarget.Latest,
true
);
const lineOf = (node) =>
sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
.line + 1;
const findings = [];
const components = [];
const componentProviderCalls = new Set();
const visit = (node) => {
const metadata = componentMetadata(node);
if (metadata) {
const providers = property(metadata, 'providers');
const calls =
providers && ts.isArrayLiteralExpression(providers)
? providers.elements.filter(isProviderCall)
: [];
calls.forEach((call) => componentProviderCalls.add(call));
components.push({ node, metadata, provides: calls.length > 0 });
}
if (ts.isIdentifier(node) && node.text === TOKEN) {
if (file !== DEFAULTS_FILE) {
findings.push({
file,
line: lineOf(node),
reason: `uses ${TOKEN}; provide the app defaults with ${PROVIDER}() from @iptvnator/ui/components/form-field-defaults`,
});
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
const visitCalls = (node) => {
if (isProviderCall(node) && !componentProviderCalls.has(node)) {
findings.push({
file,
line: lineOf(node),
reason: `calls ${PROVIDER}() outside a component's providers; a root or route provider loads Material's form field on startup`,
});
}
ts.forEachChild(node, visitCalls);
};
visitCalls(sourceFile);
for (const { node, metadata, provides } of components) {
const template = await componentTemplate(metadata, file, readTemplate);
if (provides || !/<mat-form-field\b/.test(template ?? '')) continue;
findings.push({
file,
line: lineOf(node),
reason: `renders <mat-form-field> without providers: [${PROVIDER}()]`,
});
}
return findings.sort((a, b) => a.line - b.line);
}
/** Tracked renderer TypeScript 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 files = listScannedFiles(rootDir);
const readTemplate = (file) => readFile(path.join(rootDir, file), 'utf8');
const findings = [];
for (const file of files) {
const source = await readFile(path.join(rootDir, file), 'utf8');
findings.push(
...(await checkComponentSource(file, source, readTemplate))
);
}
if (findings.length > 0) {
console.error(
'Form-field defaults must be provided per component, not in the root injector:'
);
for (const { file, line, reason } of findings) {
console.error(`- ${file}:${line} ${reason}`);
}
process.exitCode = 1;
} else {
console.log(
`Checked ${files.length} renderer files; every <mat-form-field> component provides the app defaults.`
);
}
}