feat(frontend): bundle the icons so the app works offline
Every <wa-icon> was fetched from ka-f.fontawesome.com at runtime —
confirmed from `performance.getEntriesByType('resource')`, 36 requests
— so offline the app had no icons at all. `setBasePath()` does not
affect the icon resolver; only the component autoloader reads it.
Overriding Web Awesome's `default` icon library fixes all 165 call
sites without changing one of them. Cross-origin requests at startup:
22 -> 0.
Three things about it are load-bearing. The set is Font Awesome Free
(CC BY 4.0, vendored with its licence by `scripts/fetch-icons.mjs`)
because the kit CDN serves Pro, which cannot be redistributed. The
names are a committed list rather than anything derived, because
twenty call sites compute their icon name from state and no static
pass can enumerate them. And a name that is not bundled is reported at
runtime to `window.__yjIconMisses` and drawn as a fallback, since a
missing icon used to be impossible — the CDN having had everything.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Vendor the icon set into the repo.
|
||||
*
|
||||
* The app used to fetch every `<wa-icon>` from ka-f.fontawesome.com at
|
||||
* runtime, which meant it had no icons at all offline, on a captive
|
||||
* portal or behind a firewall (audit H-4 / perf.M9). Bundling them is
|
||||
* the fix; this script is how the bundle is produced, so "where did
|
||||
* these SVGs come from" has an answer that is a command rather than a
|
||||
* memory.
|
||||
*
|
||||
* IT MUST BE FONT AWESOME **FREE**. The kit CDN the app was hitting
|
||||
* serves *Pro* SVGs — every file carries a "Commercial License"
|
||||
* comment — and those cannot be redistributed in this repository. The
|
||||
* Free set is CC BY 4.0, which can, with attribution; LICENSE.txt is
|
||||
* copied next to the icons for exactly that reason. Every name the app
|
||||
* uses happens to exist in Free, so this costs nothing visually, but
|
||||
* a future addition might not: if a name is missing here, pick a
|
||||
* different icon rather than reaching for the Pro one.
|
||||
*
|
||||
* Usage:
|
||||
* node frontend/scripts/fetch-icons.mjs
|
||||
*
|
||||
* Reads names from src/icons/names.txt, writes src/assets/icons/fa/.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync,
|
||||
readdirSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const FRONTEND = resolve(HERE, '..');
|
||||
const NAMES = resolve(FRONTEND, 'src/icons/names.txt');
|
||||
const DEST = resolve(FRONTEND, 'src/assets/icons/fa');
|
||||
const PKG = '@fortawesome/fontawesome-free@7.3.1';
|
||||
|
||||
const names = readFileSync(NAMES, 'utf8')
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/#.*$/, '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const work = mkdtempSync(join(tmpdir(), 'yj-icons-'));
|
||||
|
||||
try {
|
||||
execFileSync('npm', ['pack', PKG], { cwd: work, stdio: 'pipe' });
|
||||
const tgz = readdirSync(work).find((f) => f.endsWith('.tgz'));
|
||||
execFileSync('tar', ['xf', tgz], { cwd: work });
|
||||
|
||||
const src = join(work, 'package');
|
||||
rmSync(DEST, { recursive: true, force: true });
|
||||
|
||||
for (const name of names) {
|
||||
const from = join(src, 'svgs', `${name}.svg`);
|
||||
if (!existsSync(from)) {
|
||||
console.error(
|
||||
`fetch-icons: '${name}' is not in Font Awesome Free.\n` +
|
||||
' Pick an icon that is; do not vendor the Pro version.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const to = join(DEST, `${name}.svg`);
|
||||
mkdirSync(dirname(to), { recursive: true });
|
||||
copyFileSync(from, to);
|
||||
}
|
||||
|
||||
copyFileSync(join(src, 'LICENSE.txt'), join(DEST, 'LICENSE.txt'));
|
||||
console.log(`fetch-icons: vendored ${names.length} icons from ${PKG}`);
|
||||
} finally {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Which icons does this app actually use?
|
||||
*
|
||||
* A static grep cannot answer that: twenty call sites pass a computed
|
||||
* name (`this.favCtrl.iconName`, `jobIcon(job)`, `TONE_ICONS[tone]`),
|
||||
* and the answer depends on state. So ask the running app instead —
|
||||
* before the icons are bundled, every one of them is a request to
|
||||
* ka-f.fontawesome.com, which makes the CDN request log an exact
|
||||
* inventory of what has to be vendored.
|
||||
*
|
||||
* This is a one-shot development tool, not part of any build. It is
|
||||
* kept because the list it produces will go stale the first time a
|
||||
* component grows a new state, and rerunning it is the cheapest way to
|
||||
* find out. `frontend/src/icons/manifest.ts` is the committed answer.
|
||||
*
|
||||
* Usage: make dev-headless SEED=default, then
|
||||
* node frontend/scripts/icon-sweep.mjs
|
||||
*/
|
||||
|
||||
import { chromium } from '../../e2e/node_modules/@playwright/test/index.mjs';
|
||||
|
||||
const URL_BASE = process.env.YJ_URL ?? 'http://localhost:34115';
|
||||
|
||||
const VIEWS = [
|
||||
'home', 'tracks', 'albums', 'artists', 'genres', 'playlists',
|
||||
'explore', 'autotag', 'downloads', 'jobs', 'settings',
|
||||
];
|
||||
|
||||
const found = new Set();
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
page.on('request', (req) => {
|
||||
const m = /fontawesome\.com\/.*\/svgs\/([^/]+)\/([^/?]+)\.svg/.exec(req.url());
|
||||
if (m) found.add(`${m[1]}/${m[2]}`);
|
||||
});
|
||||
|
||||
await page.goto(URL_BASE, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
for (const view of VIEWS) {
|
||||
await page.evaluate((v) => document.dispatchEvent(
|
||||
new CustomEvent('navigate', { detail: { view: v } }),
|
||||
), view);
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
// Also collect what is in the DOM but may have been served from the
|
||||
// icon module's own cache rather than re-requested.
|
||||
const inDom = await page.evaluate(() => {
|
||||
const names = new Set();
|
||||
const walk = (root) => {
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
if (el.tagName === 'WA-ICON' && el.getAttribute('name')) {
|
||||
names.add(el.getAttribute('name'));
|
||||
}
|
||||
if (el.shadowRoot) walk(el.shadowRoot);
|
||||
}
|
||||
};
|
||||
walk(document);
|
||||
|
||||
return [...names];
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
for (const n of inDom) {
|
||||
if (![...found].some((f) => f.endsWith('/' + n))) found.add(`?/${n}`);
|
||||
}
|
||||
|
||||
console.log([...found].sort().join('\n'));
|
||||
console.log(`\n${found.size} icons`);
|
||||
Reference in New Issue
Block a user