mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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>
This commit is contained in:
1 parent
7550a22a41
commit
ced7386dfa
12 files changed
+389
-64
No files matched your search
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: fix
|
||||
area: a11y
|
||||
---
|
||||
|
||||
The radio player's play and pause button now shows the keyboard focus ring
|
||||
when you tab to it, like every other control, and the app loads a little less
|
||||
style code at startup.
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { pressTab } from './e2e-helpers';
|
||||
import { expect } from './fixtures';
|
||||
|
||||
/** The computed colour a custom property resolves to on this page. */
|
||||
async function computedColor(page: Page, property: string): Promise<string> {
|
||||
return page.evaluate((name) => {
|
||||
const probe = document.createElement('span');
|
||||
probe.style.color = `var(${name})`;
|
||||
document.body.append(probe);
|
||||
const color = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
return color;
|
||||
}, property);
|
||||
}
|
||||
|
||||
/**
|
||||
* The radio player's play/pause control is a `mat-icon-button` drawn as the
|
||||
* 56px primary circle the fab used to draw (the fab tokens alone were about
|
||||
* 6 KB of the initial stylesheet), with the icon centred, and keyboard focus
|
||||
* draws the app ring that the fab reset with `outline: none`.
|
||||
*/
|
||||
export async function verifyRadioPlayControl(
|
||||
page: Page,
|
||||
browserName: string
|
||||
): Promise<void> {
|
||||
const player = page.locator('app-audio-player');
|
||||
const play = player.locator('.play-btn');
|
||||
await expect(play).toBeVisible();
|
||||
await expect(play).toHaveClass(/mat-mdc-icon-button/);
|
||||
await expect(play).not.toHaveClass(/mat-mdc-fab/);
|
||||
|
||||
const style = await play.evaluate((button) => {
|
||||
const icon = button.querySelector('mat-icon') as HTMLElement;
|
||||
const box = button.getBoundingClientRect();
|
||||
const iconBox = icon.getBoundingClientRect();
|
||||
const { width, height, borderRadius, backgroundColor } =
|
||||
getComputedStyle(button);
|
||||
const centre = (rect: DOMRect) => [
|
||||
rect.left + rect.width / 2,
|
||||
rect.top + rect.height / 2,
|
||||
];
|
||||
const [x, y] = centre(box);
|
||||
const [iconX, iconY] = centre(iconBox);
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
borderRadius,
|
||||
backgroundColor,
|
||||
iconColor: getComputedStyle(icon).color,
|
||||
iconFontSize: getComputedStyle(icon).fontSize,
|
||||
iconOffset: [Math.round(iconX - x), Math.round(iconY - y)],
|
||||
};
|
||||
});
|
||||
expect(style).toEqual({
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: await computedColor(page, '--app-selection-color'),
|
||||
iconColor: 'rgb(255, 255, 255)',
|
||||
iconFontSize: '32px',
|
||||
iconOffset: [0, 0],
|
||||
});
|
||||
|
||||
await player.locator('.skip-btn').first().focus();
|
||||
await pressTab(page, browserName);
|
||||
await expect(play).toBeFocused();
|
||||
const ring = await play.evaluate((button) => {
|
||||
const { outlineStyle, outlineWidth, outlineColor } =
|
||||
getComputedStyle(button);
|
||||
return { outlineStyle, outlineWidth, outlineColor };
|
||||
});
|
||||
expect(ring).toEqual({
|
||||
outlineStyle: 'solid',
|
||||
outlineWidth: '2px',
|
||||
outlineColor: await computedColor(page, '--app-focus-ring'),
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture';
|
||||
import { verifyStalkerOpenInPlaylist } from './stalker-open-in-playlist.fixture';
|
||||
import { playFirstItvChannel } from './stalker-itv-playback.fixture';
|
||||
import { verifyRadioPlayControl } from './stalker-radio-player.fixture';
|
||||
import {
|
||||
AUTH_REJECTED_MAC,
|
||||
BACKEND_PROXY,
|
||||
@@ -296,6 +297,7 @@ test('@stalker ITV playback survives a category switch', async ({ page }) => {
|
||||
|
||||
test('@stalker radio — stations use the inline audio player without EPG', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await addStalkerPortal(page);
|
||||
|
||||
@@ -333,6 +335,7 @@ test('@stalker radio — stations use the inline audio player without EPG', asyn
|
||||
await expect(page.locator('app-audio-player')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await verifyRadioPlayControl(page, browserName);
|
||||
|
||||
// Level 2: only the categories rail folds; the channels header turns
|
||||
// its title into a category dropdown that opens the same rail as a
|
||||
|
||||
@@ -65,6 +65,9 @@ $dark-theme: mat.define-theme(
|
||||
// `@angular/material/<entry>` import and remove it with the last one:
|
||||
// `pnpm run styles:material-theme-scope:validate` (CI) checks both
|
||||
// directions, and `core` (ripples, options, pseudo-checkboxes) always stays.
|
||||
// `fab` follows the templates instead: the button import carries its
|
||||
// directives either way, so it is listed only while a template renders
|
||||
// `mat-fab` or `mat-mini-fab` (its tokens alone were about 6 KB).
|
||||
$material-components: (
|
||||
core,
|
||||
card,
|
||||
@@ -81,7 +84,6 @@ $material-components: (
|
||||
checkbox,
|
||||
button,
|
||||
icon-button,
|
||||
fab,
|
||||
snack-bar,
|
||||
progress-spinner,
|
||||
button-toggle,
|
||||
|
||||
@@ -136,7 +136,10 @@ Everything else is lazy by these rules:
|
||||
and the dark colours. Add an entry with a new `@angular/material/<entry>`
|
||||
import and remove it with the last one;
|
||||
`pnpm run styles:material-theme-scope:validate` (CI) checks both directions
|
||||
(`core` always stays, `button` brings `icon-button` and `fab`).
|
||||
(`core` always stays, `button` brings `icon-button`; `fab` is listed only
|
||||
while a runtime template renders `mat-fab` or `mat-mini-fab`, since the
|
||||
button import carries the directives either way and the fab tokens alone
|
||||
are about 6 KB).
|
||||
- **No animations module.** Nothing declares an Angular animation trigger and
|
||||
Material 22 animates with CSS, deciding from `prefers-reduced-motion` itself,
|
||||
so `app.config.ts` does not call `provideAnimations()`; the Electron
|
||||
|
||||
@@ -205,15 +205,24 @@
|
||||
|
||||
// Play / Pause uses the unified app primary, not the station's dominant color
|
||||
// — same blue as every other CTA so radio doesn't feel like a different app.
|
||||
// An icon button at the fab's 56px, filled: the fab tokens were about 6 KB of
|
||||
// the initial stylesheet for this one control, and the fab also reset the
|
||||
// keyboard focus ring (`outline: none` on focus) that the icon button keeps.
|
||||
.play-btn {
|
||||
@include mat.fab-overrides(
|
||||
@include mat.icon-button-overrides(
|
||||
(
|
||||
container-color: var(--app-selection-color, var(--mat-sys-primary)),
|
||||
foreground-color: #fff,
|
||||
state-layer-size: 56px,
|
||||
icon-size: 32px,
|
||||
icon-color: #fff,
|
||||
container-shape: 50%,
|
||||
state-layer-color: #fff,
|
||||
ripple-color: color-mix(in srgb, #fff 12%, transparent),
|
||||
)
|
||||
);
|
||||
box-shadow: none !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--app-selection-color, var(--mat-sys-primary));
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -86,8 +86,8 @@ import { PlaybackHistoryConfirmation } from '../playback-history/playback-histor
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
class="play-btn"
|
||||
mat-fab
|
||||
[matTooltip]="
|
||||
(playState() === 'play'
|
||||
? 'AUDIO_PLAYER.PAUSE'
|
||||
|
||||
@@ -4,6 +4,12 @@ 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
|
||||
@@ -25,43 +31,6 @@ function isScanned(file) {
|
||||
return !/\.(spec|test)\.[cm]?[jt]s$/.test(file);
|
||||
}
|
||||
|
||||
/** The object literal of an `@Component({...})` decorator, if `node` is one. */
|
||||
function componentMetadata(node) {
|
||||
if (
|
||||
!ts.isDecorator(node) ||
|
||||
!ts.isCallExpression(node.expression) ||
|
||||
!ts.isIdentifier(node.expression.expression) ||
|
||||
node.expression.expression.text !== 'Component'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const [metadata] = node.expression.arguments;
|
||||
return metadata && ts.isObjectLiteralExpression(metadata)
|
||||
? metadata
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function property(metadata, name) {
|
||||
return metadata.properties.find(
|
||||
(entry) =>
|
||||
ts.isPropertyAssignment(entry) &&
|
||||
(ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name)) &&
|
||||
entry.name.text === name
|
||||
)?.initializer;
|
||||
}
|
||||
|
||||
/** Literal text of a quoted, backticked or interpolated template. */
|
||||
function literalText(node) {
|
||||
if (ts.isStringLiteralLike(node)) return node.text;
|
||||
if (ts.isTemplateExpression(node)) {
|
||||
return [
|
||||
node.head.text,
|
||||
...node.templateSpans.map((span) => span.literal.text),
|
||||
].join('');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isProviderCall(node) {
|
||||
return (
|
||||
ts.isCallExpression(node) &&
|
||||
@@ -125,14 +94,7 @@ export async function checkComponentSource(file, source, readTemplate) {
|
||||
visitCalls(sourceFile);
|
||||
|
||||
for (const { node, metadata, provides } of components) {
|
||||
const inline = property(metadata, 'template');
|
||||
const url = property(metadata, 'templateUrl');
|
||||
let template = inline ? literalText(inline) : undefined;
|
||||
if (template === undefined && url && ts.isStringLiteralLike(url)) {
|
||||
template = await readTemplate(
|
||||
path.posix.join(path.posix.dirname(file), url.text)
|
||||
);
|
||||
}
|
||||
const template = await componentTemplate(metadata, file, readTemplate);
|
||||
if (provides || !/<mat-form-field\b/.test(template ?? '')) continue;
|
||||
findings.push({
|
||||
file,
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { isScanned, stripComments } from './check-theme-references.mjs';
|
||||
import { componentTemplates } from './component-templates.mjs';
|
||||
|
||||
/**
|
||||
* `apps/web/src/m3-theme.scss` themes only the Material components the app
|
||||
@@ -12,7 +13,9 @@ import { isScanned, stripComments } from './check-theme-references.mjs';
|
||||
* 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.
|
||||
* must leave the list. The fab theme follows the templates instead: the
|
||||
* button chunk carries its directives either way, so only a rendered
|
||||
* `mat-fab` or `mat-mini-fab` earns its tokens.
|
||||
*/
|
||||
|
||||
/** Angular renderer sources that load the theme (the remote control has its own styles). */
|
||||
@@ -40,7 +43,7 @@ export const THEMES_BY_ENTRY_POINT = new Map([
|
||||
['autocomplete', ['autocomplete']],
|
||||
['badge', ['badge']],
|
||||
['bottom-sheet', ['bottom-sheet']],
|
||||
['button', ['button', 'icon-button', 'fab']],
|
||||
['button', ['button', 'icon-button']],
|
||||
['button-toggle', ['button-toggle']],
|
||||
['card', ['card']],
|
||||
['checkbox', ['checkbox']],
|
||||
@@ -98,8 +101,55 @@ export function usedEntryPoints(sources) {
|
||||
return importers;
|
||||
}
|
||||
|
||||
/** The theme names the imported entry points need, each with a reason. */
|
||||
export function requiredThemes(importers) {
|
||||
/**
|
||||
* Themes a rendered element, not an import, calls for. The button entry
|
||||
* point carries the fab directives whether or not a template uses them, but
|
||||
* their tokens are about 6 KB of the initial stylesheet that only an element
|
||||
* with one of the fab attribute selectors reads. The pattern matches that
|
||||
* attribute on an element, never in text, a class value or a comment.
|
||||
*/
|
||||
export const THEMES_BY_TEMPLATE = new Map([
|
||||
[
|
||||
'fab',
|
||||
/<[a-z][^\s<>/]*(?:[^<>"']|"[^"]*"|'[^']*')*?\s(mat-fab|mat-mini-fab|matFab|matMiniFab)(?=[\s/>=])/i,
|
||||
],
|
||||
]);
|
||||
|
||||
/**
|
||||
* An Angular template knows HTML comments only: `//` there is text or part
|
||||
* of a URL, never a comment, so the source-code rules of `stripComments`
|
||||
* would blank a real element after it.
|
||||
*/
|
||||
function stripHtmlComments(template) {
|
||||
return template.replace(/<!--[\s\S]*?-->/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The template-gated themes the runtime templates render, each with the
|
||||
* first component template that renders the element and the attribute it
|
||||
* carries. `readTemplate` reads a `templateUrl` relative to its component.
|
||||
*/
|
||||
export async function templateThemeUsers(sources, readTemplate) {
|
||||
const users = new Map();
|
||||
for (const { file, source } of sources) {
|
||||
if (users.size === THEMES_BY_TEMPLATE.size) break;
|
||||
const templates = await componentTemplates(file, source, readTemplate);
|
||||
for (const template of templates) {
|
||||
const markup = stripHtmlComments(template);
|
||||
for (const [name, element] of THEMES_BY_TEMPLATE) {
|
||||
const match = users.has(name) ? null : element.exec(markup);
|
||||
if (match) users.set(name, { file, selector: match[1] });
|
||||
}
|
||||
}
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
/**
|
||||
* The theme names the imported entry points and the rendered templates
|
||||
* need, each with a reason.
|
||||
*/
|
||||
export function requiredThemes(importers, templateUsers = new Map()) {
|
||||
const required = new Map(
|
||||
ALWAYS_THEMED.map((name) => [name, 'always themed'])
|
||||
);
|
||||
@@ -113,6 +163,9 @@ export function requiredThemes(importers) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [name, { file, selector }] of templateUsers) {
|
||||
if (!required.has(name)) required.set(name, `${selector} in ${file}`);
|
||||
}
|
||||
return required;
|
||||
}
|
||||
|
||||
@@ -180,7 +233,11 @@ if (isMain) {
|
||||
source: await readFile(path.join(rootDir, file), 'utf8'),
|
||||
}))
|
||||
);
|
||||
const required = requiredThemes(usedEntryPoints(sources));
|
||||
const readTemplate = (file) => readFile(path.join(rootDir, file), 'utf8');
|
||||
const required = requiredThemes(
|
||||
usedEntryPoints(sources),
|
||||
await templateThemeUsers(sources, readTemplate)
|
||||
);
|
||||
const scope = themedComponents(
|
||||
await readFile(path.join(rootDir, THEME_FILE), 'utf8')
|
||||
);
|
||||
@@ -188,7 +245,7 @@ if (isMain) {
|
||||
|
||||
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:`
|
||||
`Material components the app imports or renders 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})`);
|
||||
@@ -197,7 +254,7 @@ if (isMain) {
|
||||
}
|
||||
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:`
|
||||
`Material components ${THEME_FILE} themes although no runtime source imports or renders them; their tokens only add to the initial stylesheet. Remove them from $material-components:`
|
||||
);
|
||||
for (const name of unused) {
|
||||
console.error(`- ${name}`);
|
||||
@@ -208,7 +265,7 @@ if (isMain) {
|
||||
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.`
|
||||
: `Checked ${files.length} renderer sources; ${THEME_FILE} themes exactly the ${scope.themed.size} Material components they use.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { test } from 'node:test';
|
||||
import {
|
||||
compareThemeScope,
|
||||
requiredThemes,
|
||||
templateThemeUsers,
|
||||
themedComponents,
|
||||
usedEntryPoints,
|
||||
} from './check-material-theme-scope.mjs';
|
||||
@@ -77,12 +78,123 @@ test('requiredThemes always keeps core and expands the button entry point', () =
|
||||
['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('requiredThemes adds the themes rendered templates call for', () => {
|
||||
const required = requiredThemes(
|
||||
new Map([['button', 'a.ts']]),
|
||||
new Map([['fab', { file: 'b.component.ts', selector: 'mat-mini-fab' }]])
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...required],
|
||||
[
|
||||
['core', 'always themed'],
|
||||
['button', '@angular/material/button in a.ts'],
|
||||
['icon-button', '@angular/material/button in a.ts'],
|
||||
['fab', 'mat-mini-fab in b.component.ts'],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('templateThemeUsers finds a fab element in the first template that renders one', async () => {
|
||||
const templates = {
|
||||
'libs/b/b.component.html':
|
||||
'<a [disabled]="count > 1" matFab href="#">Up</a>',
|
||||
};
|
||||
const users = await templateThemeUsers(
|
||||
[
|
||||
{
|
||||
file: 'libs/a/a.component.ts',
|
||||
source: lines(
|
||||
'@Component({',
|
||||
' template: `',
|
||||
' <!-- <button mat-fab></button> -->',
|
||||
' <p title="a > b">Use mat-fab or matMiniFab here</p>',
|
||||
' <button class="mat-fab mat-mini-fab">mat-fab</button>',
|
||||
' <button mat-fab-like matFabulous></button>',
|
||||
' `,',
|
||||
'})',
|
||||
'export class A {}'
|
||||
),
|
||||
},
|
||||
{
|
||||
file: 'libs/b/b.component.ts',
|
||||
source: lines(
|
||||
'@Component({',
|
||||
" templateUrl: './b.component.html',",
|
||||
'})',
|
||||
'export class B {}'
|
||||
),
|
||||
},
|
||||
{
|
||||
file: 'libs/c/c.component.ts',
|
||||
source: lines(
|
||||
'@Component({',
|
||||
' template: `<button mat-mini-fab (click)="go()"></button>`,',
|
||||
'})',
|
||||
'export class C {}'
|
||||
),
|
||||
},
|
||||
],
|
||||
async (file) => templates[file]
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...users],
|
||||
[['fab', { file: 'libs/b/b.component.ts', selector: 'matFab' }]]
|
||||
);
|
||||
});
|
||||
|
||||
test('templateThemeUsers strips HTML comments only: a // in text or a URL hides nothing', async () => {
|
||||
const users = await templateThemeUsers(
|
||||
[
|
||||
{
|
||||
file: 'libs/e/e.component.ts',
|
||||
source: lines(
|
||||
'@Component({',
|
||||
' template: `',
|
||||
' <!-- <button mat-fab></button> -->',
|
||||
' <span>now // later</span> <a href="https://x/up" mat-fab>Up</a>',
|
||||
' <button title="Help // tips" mat-mini-fab></button>',
|
||||
' `,',
|
||||
'})',
|
||||
'export class E {}'
|
||||
),
|
||||
},
|
||||
],
|
||||
async () => undefined
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...users],
|
||||
[['fab', { file: 'libs/e/e.component.ts', selector: 'mat-fab' }]]
|
||||
);
|
||||
});
|
||||
|
||||
test('templateThemeUsers finds nothing without a rendered fab', async () => {
|
||||
const users = await templateThemeUsers(
|
||||
[
|
||||
{
|
||||
file: 'libs/d/d.component.ts',
|
||||
source: lines(
|
||||
'@Component({',
|
||||
' template: `<button mat-icon-button class="play-btn"></button>`,',
|
||||
'})',
|
||||
'export class D {}'
|
||||
),
|
||||
},
|
||||
{ file: 'libs/d/d.service.ts', source: 'export const fab = 1;' },
|
||||
],
|
||||
async () => undefined
|
||||
);
|
||||
|
||||
assert.deepEqual([...users], []);
|
||||
});
|
||||
|
||||
test('themedComponents reads the list, direct includes and the all-components mixin', () => {
|
||||
assert.deepEqual(
|
||||
themedComponents(
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import path from 'node:path';
|
||||
import ts from 'typescript';
|
||||
|
||||
/**
|
||||
* The templates the app renders, found from the `@Component` decorators of
|
||||
* its TypeScript sources: an inline `template` literal, or the file a
|
||||
* `templateUrl` names. A guard that asks "does the app render this
|
||||
* element?" reads these, not every `.html` file: an orphaned template that
|
||||
* no component names never reaches a user.
|
||||
*/
|
||||
|
||||
/** The object literal of an `@Component({...})` decorator, if `node` is one. */
|
||||
export function componentMetadata(node) {
|
||||
if (
|
||||
!ts.isDecorator(node) ||
|
||||
!ts.isCallExpression(node.expression) ||
|
||||
!ts.isIdentifier(node.expression.expression) ||
|
||||
node.expression.expression.text !== 'Component'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const [metadata] = node.expression.arguments;
|
||||
return metadata && ts.isObjectLiteralExpression(metadata)
|
||||
? metadata
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** The initializer of the `name` property of a metadata literal. */
|
||||
export function property(metadata, name) {
|
||||
return metadata.properties.find(
|
||||
(entry) =>
|
||||
ts.isPropertyAssignment(entry) &&
|
||||
(ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name)) &&
|
||||
entry.name.text === name
|
||||
)?.initializer;
|
||||
}
|
||||
|
||||
/** Literal text of a quoted, backticked or interpolated template. */
|
||||
function literalText(node) {
|
||||
if (ts.isStringLiteralLike(node)) return node.text;
|
||||
if (ts.isTemplateExpression(node)) {
|
||||
return [
|
||||
node.head.text,
|
||||
...node.templateSpans.map((span) => span.literal.text),
|
||||
].join('');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The template text of one `@Component` metadata literal: its inline
|
||||
* `template`, or else the file its `templateUrl` names, which `readTemplate`
|
||||
* reads by a path relative to the component's `file`. Undefined when the
|
||||
* component declares neither as a literal.
|
||||
*/
|
||||
export async function componentTemplate(metadata, file, readTemplate) {
|
||||
const inline = property(metadata, 'template');
|
||||
const template = inline ? literalText(inline) : undefined;
|
||||
const url = property(metadata, 'templateUrl');
|
||||
if (template !== undefined || !url || !ts.isStringLiteralLike(url)) {
|
||||
return template;
|
||||
}
|
||||
return readTemplate(path.posix.join(path.posix.dirname(file), url.text));
|
||||
}
|
||||
|
||||
/**
|
||||
* The template text of every `@Component` in a TypeScript source, in
|
||||
* declaration order. A source without a component decorator is not parsed.
|
||||
*/
|
||||
export async function componentTemplates(file, source, readTemplate) {
|
||||
if (!source.includes('@Component')) return [];
|
||||
const sourceFile = ts.createSourceFile(
|
||||
file,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true
|
||||
);
|
||||
const components = [];
|
||||
const visit = (node) => {
|
||||
const metadata = componentMetadata(node);
|
||||
if (metadata) components.push(metadata);
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sourceFile);
|
||||
const templates = await Promise.all(
|
||||
components.map((metadata) =>
|
||||
componentTemplate(metadata, file, readTemplate)
|
||||
)
|
||||
);
|
||||
return templates.filter((template) => template !== undefined);
|
||||
}
|
||||
@@ -3,11 +3,11 @@
|
||||
"journeys": {
|
||||
"launch": {
|
||||
"renderer.initialBytes": {
|
||||
"value": 1345807,
|
||||
"value": 1329249,
|
||||
"unit": "bytes",
|
||||
"slack": 4096,
|
||||
"updatedAt": "2026-10-13",
|
||||
"evidencePr": 1931,
|
||||
"updatedAt": "2026-10-14",
|
||||
"evidencePr": 1938,
|
||||
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
|
||||
},
|
||||
"renderer.ipcCallsToFirstCard": {
|
||||
|
||||
Reference in new issue
Block a user