diff --git a/CHANGELOG.md b/CHANGELOG.md index 21544fa..17a3f9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +### Changed + +- The Material Symbols icon font is now requested as a subset of the glyphs the + app actually renders, and the unused Material Icons Round stylesheet is gone. + Loading the app pulled 442 kB from Google Fonts over six requests, 330 kB of + it the complete icon font served for the 28 glyphs on screen; it now pulls + 115 kB over five. `src/materialSymbolsSubset.test.mjs` fails when a source + names a glyph the subset is missing, because an absent glyph renders as its + own name rather than as a missing-glyph box. + ### Fixed - Mapped-site outages show their scheduled retry countdown and distinguish diff --git a/index.html b/index.html index e8b0b8d..50a7951 100644 --- a/index.html +++ b/index.html @@ -9,8 +9,11 @@ - - + +
diff --git a/src/materialSymbolsSubset.test.mjs b/src/materialSymbolsSubset.test.mjs new file mode 100644 index 0000000..3349e74 --- /dev/null +++ b/src/materialSymbolsSubset.test.mjs @@ -0,0 +1,88 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SRC_ROOT = fileURLToPath(new URL('.', import.meta.url)); +const REPO_ROOT = path.resolve(SRC_ROOT, '..'); +const INDEX_HTML = path.join(REPO_ROOT, 'index.html'); + +/** The glyph written as element text: `radar`. */ +const SPAN_TEXT = /class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*([a-z0-9_]+)\s*} glyph -> the first file that names it. + */ +function referencedGlyphs() { + const found = new Map(); + for (const file of sourceFiles()) { + const source = readFileSync(file, 'utf8'); + const relative = path.relative(REPO_ROOT, file).split(path.sep).join('/'); + const add = (glyph) => { if (!found.has(glyph)) found.set(glyph, relative); }; + for (const match of source.matchAll(SPAN_TEXT)) add(match[1]); + for (const match of source.matchAll(TEXT_ASSIGNMENT)) { + const statement = match[1].replaceAll('?.', '.'); + const assigned = statement.includes('?') ? statement.slice(statement.indexOf('?') + 1) : statement; + for (const literal of assigned.matchAll(STRING_LITERAL)) add(literal[1]); + } + } + return found; +} + +/** The `icon_names` list index.html asks Google for. */ +function subsettedGlyphs(html = readFileSync(INDEX_HTML, 'utf8')) { + const match = /Material\+Symbols\+Outlined[^"]*[?&]icon_names=([a-z0-9_,]+)/.exec(html); + assert.ok(match, 'index.html must request Material Symbols with an icon_names subset'); + return new Set(match[1].split(',')); +} + +test('every glyph the sources render is in the icon_names subset', () => { + const subset = subsettedGlyphs(); + const missing = [...referencedGlyphs()] + .filter(([glyph]) => !subset.has(glyph)) + .map(([glyph, file]) => `${glyph} (${file})`); + + assert.deepEqual( + missing, + [], + 'Glyphs named by the sources but absent from the index.html icon_names list. ' + + 'Add them there — an unlisted glyph renders as its own name on screen: ' + + missing.join(', '), + ); +}); + +test('the unused Material Icons Round family is not loaded', () => { + // A second icon font, 173 kB, for a family no source ever uses — and + // src/cockpitMarkup.test.mjs already asserts the markup must not use it. + const html = readFileSync(INDEX_HTML, 'utf8'); + assert.doesNotMatch(html, /Material\+Icons\+Round/, 'index.html loads an icon font nothing renders'); +});