openvidu/openvidu-dashboard/dashboard-ce/frontend/scripts/icons.mjs

159 lines
5.5 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
// Material Symbols sprite for the dashboard.
// build: compiles the rounded SVGs named in the manifests into one icon set for MatIconRegistry.addSvgIconSet.
// check: fails when a source file uses an icon no manifest lists, or a manifest lists an icon none of them uses.
// Usage: icons.mjs build|check [--sprite <file>] [<icons.json>...]. Each manifest lists `icons` and the `sources`
// directories, relative to itself, whose files use them; by default the dashboard-ui manifest and sprite.
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(3);
const spriteIndex = args.indexOf('--sprite');
const spritePath =
spriteIndex < 0 ? join(root, 'projects/dashboard-ui/assets/icons.svg') : resolve(args[spriteIndex + 1] ?? '');
const manifestPaths = args
.filter((_, index) => spriteIndex < 0 || (index !== spriteIndex && index !== spriteIndex + 1))
.map((path) => realpathSync(resolve(path)));
if (manifestPaths.length === 0) {
manifestPaths.push(join(root, 'projects/dashboard-ui/icons.json'));
}
const manifests = manifestPaths.map((path) => {
const { icons, sources } = JSON.parse(readFileSync(path, 'utf8'));
return { path, icons, sources: sources.map((source) => join(dirname(path), source)) };
});
const sourceDirs = manifests.flatMap((manifest) => manifest.sources);
// Icon names in templates, route metadata and component defaults: svgIcon="x", [svgIcon]="cond ? 'x' : 'y'",
// icon="x", icon: 'x' and icon = input('x').
const USAGE_PATTERNS = [
/\bsvgIcon="([a-z0-9_-]+)"/g,
/\[svgIcon\]="([^"]+)"/g,
/\bicon="([a-z0-9_-]+)"/g,
/\bicon:\s*'([a-z0-9_-]+)'/g,
/\bicon\s*=\s*input\('([a-z0-9_-]+)'/g
];
// In a bound [svgIcon] expression, the literals that are results: the whole expression or a ternary branch.
const BOUND_RESULT = /(?:^|[?:])\s*'([a-z0-9_-]+)'/g;
// Records of icons, e.g. `export const THEME_ICONS = { system: 'brightness_auto', … };`.
const ICON_RECORD = /const [A-Z_]*ICONS\b[^=]*=\s*\{([^}]*)\}/g;
// Sidenav entries of route files also show the filled variant when active.
const ROUTE_FILE = /routes\.ts$/;
function iconsDir() {
const require = createRequire(import.meta.url);
return join(dirname(require.resolve('@material-symbols/svg-400/package.json')), 'rounded');
}
function readManifest() {
return [...new Set(manifests.flatMap((manifest) => manifest.icons))].sort();
}
function build() {
const dir = iconsDir();
const symbols = readManifest().map((name) => {
const svg = readFileSync(join(dir, `${name}.svg`), 'utf8');
const viewBox = /viewBox="([^"]+)"/.exec(svg)?.[1];
const body = /<svg[^>]*>([\s\S]*)<\/svg>/.exec(svg)?.[1];
if (viewBox === undefined || body === undefined) {
throw new Error(`Unexpected SVG for icon ${name}`);
}
return `<svg id="${name}" viewBox="${viewBox}">${body}</svg>`;
});
mkdirSync(dirname(spritePath), { recursive: true });
writeFileSync(spritePath, `<svg xmlns="http://www.w3.org/2000/svg"><defs>${symbols.join('')}</defs></svg>\n`);
console.log(`icons: ${symbols.length} icons → ${relative(process.cwd(), spritePath)}`);
}
function* sourceFiles(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
yield* sourceFiles(path);
} else if (/\.(ts|html)$/.test(entry.name) && !entry.name.endsWith('.spec.ts')) {
yield path;
}
}
}
function check() {
const manifest = new Set(readManifest());
const used = new Map();
const listed = manifests.map((manifest) => relative(process.cwd(), manifest.path)).join(', ');
for (const dir of sourceDirs) {
for (const file of sourceFiles(dir)) {
const text = readFileSync(file, 'utf8');
const add = (name) => {
used.set(name, [...(used.get(name) ?? []), relative(process.cwd(), file)]);
};
for (const record of text.matchAll(ICON_RECORD)) {
for (const literal of record[1].matchAll(/'([a-z0-9_-]+)'/g)) {
add(literal[1]);
}
}
for (const pattern of USAGE_PATTERNS) {
for (const match of text.matchAll(pattern)) {
const names = pattern.source.startsWith('\\[svgIcon')
? [...match[1].matchAll(BOUND_RESULT)].map((literal) => literal[1])
: [match[1]];
for (const name of names) {
add(name);
if (ROUTE_FILE.test(file)) {
add(`${name}-fill`);
}
}
}
}
}
}
const missing = [...used.keys()].filter((name) => !manifest.has(name)).sort();
const unused = [...manifest].filter((name) => !used.has(name));
const unknown = [...manifest].filter((name) => !existsSync(join(iconsDir(), `${name}.svg`)));
for (const name of missing) {
console.error(`icons: "${name}" is used in ${used.get(name).join(', ')} but missing from ${listed}`);
}
for (const name of unused) {
console.error(`icons: "${name}" is listed in ${listed} but never used`);
}
for (const name of unknown) {
console.error(`icons: "${name}" is not a rounded Material Symbol`);
}
if (missing.length > 0 || unused.length > 0 || unknown.length > 0) {
process.exit(1);
}
console.log(`icons: ${manifest.size} icons, all used and listed`);
}
const command = process.argv[2];
if (command === 'build') {
build();
} else if (command === 'check') {
check();
} else {
console.error('Usage: icons.mjs build|check');
process.exit(1);
}