mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
Merge remote-tracking branch 'origin/master' into claude/design-handoff-prompt-d0aee4
This commit is contained in:
commit
31899d7b58
54 files changed
+4075
-1631
No files matched your search
@@ -0,0 +1,214 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { isScanned, stripComments } from './check-theme-references.mjs';
|
||||
|
||||
/**
|
||||
* `apps/web/src/m3-theme.scss` themes only the Material components the app
|
||||
* imports, through its `$material-components` list; `all-component-themes`
|
||||
* also emitted about 24 KB of tokens for components nothing uses. This guard
|
||||
* keeps the list in step with the imports in both directions: a component a
|
||||
* new `@angular/material/<entry>` import brings in must be themed, or it
|
||||
* renders with no colours at all, and a component whose last import went
|
||||
* must leave the list.
|
||||
*/
|
||||
|
||||
/** Angular renderer sources that load the theme (the remote control has its own styles). */
|
||||
const SCANNED_PATHSPECS = ['apps/web/*.ts', 'libs/*.ts'];
|
||||
|
||||
export const THEME_FILE = 'apps/web/src/m3-theme.scss';
|
||||
|
||||
/**
|
||||
* Runtime imports of a Material entry point: a static import that is not
|
||||
* type-only (a `type` specifier list leaves nothing in the bundle) and a
|
||||
* dynamic `import()` such as the one LazyMatDialog makes. A type-only
|
||||
* import renders no component and so needs no theme.
|
||||
*/
|
||||
const STATIC_IMPORT =
|
||||
/(?<![\w$.])import\s+(?!type\s)[^;]*?from\s+['"]@angular\/material\/([a-z-]+)['"]/g;
|
||||
const DYNAMIC_IMPORT =
|
||||
/\bimport\(\s*['"]@angular\/material\/([a-z-]+)['"]\s*\)/g;
|
||||
|
||||
/**
|
||||
* The theme mixins (`mat.<name>-theme`, `mat.<name>-color`) each Material
|
||||
* entry point needs. An entry point that is not listed has no themed
|
||||
* component.
|
||||
*/
|
||||
export const THEMES_BY_ENTRY_POINT = new Map([
|
||||
['autocomplete', ['autocomplete']],
|
||||
['badge', ['badge']],
|
||||
['bottom-sheet', ['bottom-sheet']],
|
||||
['button', ['button', 'icon-button', 'fab']],
|
||||
['button-toggle', ['button-toggle']],
|
||||
['card', ['card']],
|
||||
['checkbox', ['checkbox']],
|
||||
['chips', ['chips']],
|
||||
['core', ['core']],
|
||||
['datepicker', ['datepicker']],
|
||||
['dialog', ['dialog']],
|
||||
['divider', ['divider']],
|
||||
['expansion', ['expansion']],
|
||||
['form-field', ['form-field']],
|
||||
['grid-list', ['grid-list']],
|
||||
['icon', ['icon']],
|
||||
['input', ['input']],
|
||||
['list', ['list']],
|
||||
['menu', ['menu']],
|
||||
['paginator', ['paginator']],
|
||||
['progress-bar', ['progress-bar']],
|
||||
['progress-spinner', ['progress-spinner']],
|
||||
['radio', ['radio']],
|
||||
['select', ['select']],
|
||||
['sidenav', ['sidenav']],
|
||||
['slide-toggle', ['slide-toggle']],
|
||||
['slider', ['slider']],
|
||||
['snack-bar', ['snack-bar']],
|
||||
['sort', ['sort']],
|
||||
['stepper', ['stepper']],
|
||||
['table', ['table']],
|
||||
['tabs', ['tabs']],
|
||||
['timepicker', ['timepicker']],
|
||||
['toolbar', ['toolbar']],
|
||||
['tooltip', ['tooltip']],
|
||||
['tree', ['tree']],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Themed whatever is imported: `core` carries the ripple, option, optgroup
|
||||
* and pseudo-checkbox tokens that select, menu and list render with.
|
||||
*/
|
||||
export const ALWAYS_THEMED = ['core'];
|
||||
|
||||
/**
|
||||
* Material entry points the runtime sources import at runtime, statically or
|
||||
* dynamically, with one importer each.
|
||||
*/
|
||||
export function usedEntryPoints(sources) {
|
||||
const importers = new Map();
|
||||
for (const { file, source } of sources) {
|
||||
const code = stripComments(source);
|
||||
for (const pattern of [STATIC_IMPORT, DYNAMIC_IMPORT]) {
|
||||
for (const match of code.matchAll(pattern)) {
|
||||
if (!importers.has(match[1])) importers.set(match[1], file);
|
||||
}
|
||||
}
|
||||
}
|
||||
return importers;
|
||||
}
|
||||
|
||||
/** The theme names the imported entry points need, each with a reason. */
|
||||
export function requiredThemes(importers) {
|
||||
const required = new Map(
|
||||
ALWAYS_THEMED.map((name) => [name, 'always themed'])
|
||||
);
|
||||
for (const [entryPoint, file] of importers) {
|
||||
for (const name of THEMES_BY_ENTRY_POINT.get(entryPoint) ?? []) {
|
||||
if (!required.has(name)) {
|
||||
required.set(
|
||||
name,
|
||||
`@angular/material/${entryPoint} in ${file}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return required;
|
||||
}
|
||||
|
||||
/**
|
||||
* The components the theme file themes: the `$material-components` list
|
||||
* plus any `mat.<name>-theme` included directly. `all` means
|
||||
* `all-component-themes` is still included, which themes everything.
|
||||
*/
|
||||
export function themedComponents(scss) {
|
||||
const themed = new Set();
|
||||
const list = scss.match(/\$material-components\s*:\s*\(([^)]*)\)/);
|
||||
if (list) {
|
||||
for (const item of list[1].split(',')) {
|
||||
const name = item.replace(/\/\/.*$/gm, '').trim();
|
||||
if (name) themed.add(name);
|
||||
}
|
||||
}
|
||||
for (const match of scss.matchAll(/@include\s+mat\.([a-z-]+)-theme\(/g)) {
|
||||
themed.add(match[1]);
|
||||
}
|
||||
return {
|
||||
themed,
|
||||
all: /@include\s+mat\.all-component-themes\(/.test(scss),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Themes that imports need but the file lacks, and themes the file carries
|
||||
* that no import needs.
|
||||
*/
|
||||
export function compareThemeScope(required, { themed, all }) {
|
||||
const unthemed = all
|
||||
? []
|
||||
: [...required].filter(([name]) => !themed.has(name));
|
||||
const unused = [...themed].filter((name) => !required.has(name));
|
||||
return { unthemed, unused };
|
||||
}
|
||||
|
||||
/** Tracked renderer sources 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 sources = await Promise.all(
|
||||
files.map(async (file) => ({
|
||||
file,
|
||||
source: await readFile(path.join(rootDir, file), 'utf8'),
|
||||
}))
|
||||
);
|
||||
const required = requiredThemes(usedEntryPoints(sources));
|
||||
const scope = themedComponents(
|
||||
await readFile(path.join(rootDir, THEME_FILE), 'utf8')
|
||||
);
|
||||
const { unthemed, unused } = compareThemeScope(required, scope);
|
||||
|
||||
if (unthemed.length > 0) {
|
||||
console.error(
|
||||
`Material components the app imports but ${THEME_FILE} does not theme; they render without colours. Add them to $material-components:`
|
||||
);
|
||||
for (const [name, reason] of unthemed) {
|
||||
console.error(`- ${name} (${reason})`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
if (unused.length > 0) {
|
||||
console.error(
|
||||
`Material components ${THEME_FILE} themes although no runtime source imports them; their tokens only add to the initial stylesheet. Remove them from $material-components:`
|
||||
);
|
||||
for (const name of unused) {
|
||||
console.error(`- ${name}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
if (!process.exitCode) {
|
||||
console.log(
|
||||
scope.all
|
||||
? `Checked ${files.length} renderer sources; ${THEME_FILE} themes every Material component through all-component-themes.`
|
||||
: `Checked ${files.length} renderer sources; ${THEME_FILE} themes exactly the ${scope.themed.size} Material components they import.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
compareThemeScope,
|
||||
requiredThemes,
|
||||
themedComponents,
|
||||
usedEntryPoints,
|
||||
} from './check-material-theme-scope.mjs';
|
||||
|
||||
const lines = (...rows) => rows.join('\n');
|
||||
|
||||
test('usedEntryPoints lists each Material entry point with its first importer', () => {
|
||||
const importers = usedEntryPoints([
|
||||
{
|
||||
file: 'a.component.ts',
|
||||
source: lines(
|
||||
"import { MatButtonModule } from '@angular/material/button';",
|
||||
'import {',
|
||||
' MatDialog,',
|
||||
"} from '@angular/material/dialog';",
|
||||
'import { MatIcon } from "@angular/material/icon";'
|
||||
),
|
||||
},
|
||||
{
|
||||
file: 'b.component.ts',
|
||||
source: "import { MatButton } from '@angular/material/button';",
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
[...importers],
|
||||
[
|
||||
['button', 'a.component.ts'],
|
||||
['dialog', 'a.component.ts'],
|
||||
['icon', 'a.component.ts'],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('usedEntryPoints counts dynamic imports and ignores type-only ones and comments', () => {
|
||||
const importers = usedEntryPoints([
|
||||
{
|
||||
file: 'lazy-mat-dialog.ts',
|
||||
source: lines(
|
||||
"import type { MatDialog } from '@angular/material/dialog';",
|
||||
"type DialogModule = typeof import('@angular/material/dialog');",
|
||||
'loadDialogModule = () =>',
|
||||
" import('@angular/material/dialog');"
|
||||
),
|
||||
},
|
||||
{
|
||||
file: 'types-only.ts',
|
||||
source: lines(
|
||||
"import type { MatTooltip } from '@angular/material/tooltip';",
|
||||
"// import { MatChipsModule } from '@angular/material/chips';",
|
||||
"/* import { MatTabsModule } from '@angular/material/tabs'; */"
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual([...importers], [['dialog', 'lazy-mat-dialog.ts']]);
|
||||
});
|
||||
|
||||
test('requiredThemes always keeps core and expands the button entry point', () => {
|
||||
const required = requiredThemes(
|
||||
new Map([
|
||||
['button', 'a.ts'],
|
||||
['tooltip', 'b.ts'],
|
||||
['testing', 'c.ts'],
|
||||
])
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...required],
|
||||
[
|
||||
['core', 'always themed'],
|
||||
['button', '@angular/material/button in a.ts'],
|
||||
['icon-button', '@angular/material/button in a.ts'],
|
||||
['fab', '@angular/material/button in a.ts'],
|
||||
['tooltip', '@angular/material/tooltip in b.ts'],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('themedComponents reads the list, direct includes and the all-components mixin', () => {
|
||||
assert.deepEqual(
|
||||
themedComponents(
|
||||
lines(
|
||||
'$material-components: (',
|
||||
' core,',
|
||||
' button, // used by every dialog',
|
||||
' icon-button,',
|
||||
');',
|
||||
'@include mat.tooltip-theme($theme);'
|
||||
)
|
||||
),
|
||||
{
|
||||
themed: new Set(['core', 'button', 'icon-button', 'tooltip']),
|
||||
all: false,
|
||||
}
|
||||
);
|
||||
assert.deepEqual(
|
||||
themedComponents('html { @include mat.all-component-themes($t); }'),
|
||||
{ themed: new Set(), all: true }
|
||||
);
|
||||
});
|
||||
|
||||
test('compareThemeScope reports missing and surplus themes', () => {
|
||||
const required = new Map([
|
||||
['core', 'always themed'],
|
||||
['button', '@angular/material/button in a.ts'],
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
compareThemeScope(required, {
|
||||
themed: new Set(['core', 'tabs']),
|
||||
all: false,
|
||||
}),
|
||||
{
|
||||
unthemed: [['button', '@angular/material/button in a.ts']],
|
||||
unused: ['tabs'],
|
||||
}
|
||||
);
|
||||
// all-component-themes leaves nothing unthemed, but is still surplus-free
|
||||
// only when the list is empty.
|
||||
assert.deepEqual(
|
||||
compareThemeScope(required, { themed: new Set(), all: true }),
|
||||
{ unthemed: [], unused: [] }
|
||||
);
|
||||
});
|
||||
@@ -3,11 +3,11 @@
|
||||
"journeys": {
|
||||
"launch": {
|
||||
"renderer.initialBytes": {
|
||||
"value": 1534604,
|
||||
"value": 1345807,
|
||||
"unit": "bytes",
|
||||
"slack": 4096,
|
||||
"updatedAt": "2026-10-13",
|
||||
"evidencePr": 1928,
|
||||
"evidencePr": 1931,
|
||||
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
|
||||
},
|
||||
"renderer.ipcCallsToFirstCard": {
|
||||
|
||||
Reference in new issue
Block a user