Add lucide icon sprite partial (todo §1); src/icons.ts generates only-used symbols from pinned lucide-static

This commit is contained in:
2026-06-15 11:44:40 +02:00
parent 30db8216e6
commit 265704a7eb
5 changed files with 132 additions and 2 deletions

33
src/icons.test.ts Normal file
View File

@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
import { ICON_NAMES, buildIconSprite } from "./icons.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..");
const lucideDir = join(rootDir, "node_modules", "lucide-static", "icons");
const partial = join(rootDir, "views", "partials", "icons.ejs");
const symbolInner = (sprite: string, id: string): string =>
sprite.match(new RegExp(`<symbol id="${id}"[^>]*>(.*?)</symbol>`))?.[1] ?? "";
test("icons partial inlines exactly the used lucide-static icons", async () => {
const built = buildIconSprite(lucideDir);
// The committed partial must be exactly the generator output — proves provenance
// and flags drift when the pinned lucide-static is bumped without regenerating.
assert.equal(readFileSync(partial, "utf8"), built);
const html = await ejs.renderFile(partial, {});
const ids = [...html.matchAll(/<symbol id="(i-[a-z-]+)"/g)].map((m) => m[1]);
assert.deepEqual(ids, Object.keys(ICON_NAMES)); // only the used icons, complete + ordered
assert.match(html.trimStart(), /^<svg width="0" height="0"[^>]*aria-hidden="true"/);
// Independent spot-checks: a wrong id→icon mapping is caught regardless of the builder.
assert.match(symbolInner(built, "i-x"), /M18 6 6 18/);
assert.match(symbolInner(built, "i-search"), /circle cx="11" cy="11" r="8"/);
assert.match(symbolInner(built, "i-kebab"), /circle cx="12" cy="12" r="1"/);
assert.match(symbolInner(built, "i-bell"), /M10\.268 21/); // lucide v1.18 path, not the mockup's older one
});

59
src/icons.ts Normal file
View File

@@ -0,0 +1,59 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
// Sprite id → lucide-static icon, the icons the UI actually references (alphabetical by id).
// Inlined as <symbol> so pages stay zero-JS: <svg><use href="#i-search"/></svg>.
export const ICON_NAMES: Record<string, string> = {
"i-alert": "circle-alert",
"i-arrow-left": "arrow-left",
"i-bell": "bell",
"i-box": "box",
"i-cal": "calendar",
"i-chart": "chart-no-axes-column",
"i-check-circle": "circle-check",
"i-chev": "chevron-right",
"i-cols": "columns-3",
"i-copy": "copy",
"i-download": "download",
"i-edit": "pencil",
"i-gear": "settings",
"i-globe": "globe",
"i-grid": "layout-grid",
"i-kebab": "ellipsis-vertical",
"i-layers": "layers",
"i-lock": "lock",
"i-logout": "log-out",
"i-mail": "mail",
"i-menu": "menu",
"i-plus": "plus",
"i-search": "search",
"i-shield": "shield",
"i-sliders": "sliders-horizontal",
"i-sort": "chevrons-up-down",
"i-trash": "trash-2",
"i-up": "chevron-up",
"i-user": "user",
"i-users": "users",
"i-x": "x",
};
// Drop lucide's license comment + <svg> wrapper, keep the drawing children (compacted).
function inner(svg: string): string {
const open = svg.indexOf(">", svg.indexOf("<svg"));
return svg.slice(open + 1, svg.lastIndexOf("</svg>")).replace(/\s*\n\s*/g, "").trim();
}
// Hidden <symbol> sprite for the used icons, sourced from the pinned lucide-static.
// Regenerates views/partials/icons.ejs; icons.test.ts asserts the committed file matches.
export function buildIconSprite(iconsDir: string): string {
const symbols = Object.entries(ICON_NAMES).map(
([id, name]) => ` <symbol id="${id}" viewBox="0 0 24 24">${inner(readFileSync(join(iconsDir, `${name}.svg`), "utf8"))}</symbol>`,
);
return [
"<%# Generated from lucide-static by src/icons.ts — regenerate on dep bump (guarded by icons.test.ts). %>",
'<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">',
...symbols,
"</svg>",
"",
].join("\n");
}