Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 145803bc59 Embed the full Font Awesome Free Solid set in the icon registry (task 7c3be300)
The generator now enumerates every icon definition exported by the
installed @fortawesome/free-solid-svg-icons (de-duplicated by canonical
iconName, sorted by name, labels in Title Case) and writes it to both
registries as FA_SOLID_ICONS. resolveIcon("fa:<name>") resolves against
the full set, with curated quick-pick entries keeping their labels; the
curated LUCIDE_ICONS and FA_ICONS exports are unchanged. Tests assert the
full set is present and resolves, and unknown fa: names still fall back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:14:22 -04:00
5 changed files with 3019 additions and 16 deletions
File diff suppressed because it is too large Load Diff
+26 -2
View File
@@ -3,7 +3,14 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
// The offline generator is the only writer of both registry artefacts.
import { OUTPUT_PATHS, REPO_ROOT, SOURCE_PATH, renderRegistry } from "../../../tools/icons/generate.mjs";
import { EMOJI_MAX_CODE_POINTS, FALLBACK_ICON, FA_ICONS, LUCIDE_ICONS, resolveIcon } from "./registry.generated";
import {
EMOJI_MAX_CODE_POINTS,
FALLBACK_ICON,
FA_ICONS,
FA_SOLID_ICONS,
LUCIDE_ICONS,
resolveIcon,
} from "./registry.generated";
type Curated = { lucide: { name: string; label: string }[]; fa: { name: string; label: string }[] };
@@ -45,7 +52,18 @@ describe("shared icon registry — one source of truth", () => {
expect(icon.viewBox).toMatch(/^0 0 \d+ 512$/);
expect(icon.paths.length, icon.name).toBeGreaterThan(0);
}
for (const icon of [...LUCIDE_ICONS, ...FA_ICONS]) {
const solidNames = FA_SOLID_ICONS.map((icon) => icon.name);
expect(solidNames.length).toBeGreaterThanOrEqual(1400);
expect(solidNames).toEqual([...solidNames].sort());
expect(new Set(solidNames).size).toBe(solidNames.length);
for (const { name } of FA_ICONS) expect(solidNames, name).toContain(name);
for (const icon of FA_SOLID_ICONS) {
expect(icon.kind).toBe("fa");
expect(icon.key).toBe(`fa:${icon.name}`);
expect(icon.viewBox).toMatch(/^0 0 \d+ 512$/);
expect(icon.paths.length, icon.name).toBeGreaterThan(0);
}
for (const icon of [...LUCIDE_ICONS, ...FA_ICONS, ...FA_SOLID_ICONS]) {
for (const d of icon.paths) expect(d, icon.key).toMatch(/^[Mm][-0-9.,\sA-Za-z]+$/);
}
});
@@ -61,6 +79,12 @@ describe("resolveIcon", () => {
expect(broom.kind).toBe("fa");
expect(broom).toBe(FA_ICONS.find((icon) => icon.name === "broom"));
// Any Free Solid name resolves, not just the curated quick-pick set.
const rocket = resolveIcon("fa:rocket");
expect(rocket).toBe(FA_SOLID_ICONS.find((icon) => icon.name === "rocket"));
expect(rocket).toMatchObject({ kind: "fa", key: "fa:rocket", label: "Rocket" });
expect(resolveIcon("fa:person-dress-burst")).toMatchObject({ kind: "fa", label: "Person Dress Burst" });
expect(resolveIcon("emoji:🐶")).toEqual({ kind: "emoji", key: "emoji:🐶", text: "🐶" });
expect(resolveIcon("emoji:👨‍👩‍👧")).toEqual({ kind: "emoji", key: "emoji:👨‍👩‍👧", text: "👨‍👩‍👧" });
});
File diff suppressed because it is too large Load Diff
+69
View File
@@ -63,6 +63,75 @@ def test_artefacts_carry_exactly_the_curated_names_and_labels() -> None:
assert 15 <= len(curated["fa"]) <= 25
def test_artefacts_carry_the_full_font_awesome_free_solid_set() -> None:
text = ARTEFACTS[0].read_text(encoding="utf-8")
solid = _generated_set(text, "FA_SOLID_ICONS")
names = [entry["name"] for entry in solid]
assert names == sorted(names)
assert len(set(names)) == len(names)
# The complete Free Solid set, not a curated pick (7.x ships ~1,400).
assert len(solid) >= 1400
for entry in solid:
assert set(entry) == {"kind", "key", "name", "label", "viewBox", "paths"}
assert entry["kind"] == "fa"
assert entry["key"] == f"fa:{entry['name']}"
assert entry["label"] == " ".join(
part[0].upper() + part[1:] for part in entry["name"].split("-")
)
assert re.fullmatch(r"0 0 \d+ \d+", entry["viewBox"])
assert entry["paths"] and all(entry["paths"])
# A well-known glyph, a barely-known one, and every curated quick-pick.
assert {"rocket", "mug-hot", "person-dress-burst"} <= set(names)
assert {e["name"] for e in _curated()["fa"]} <= set(names)
assert "rocket-ship-9000" not in names
def test_resolve_icon_resolves_any_free_solid_name() -> None:
node = shutil.which("node")
if node is None:
pytest.skip("needs node")
script = (
"const m = await import(process.argv[1]);"
"const out = {};"
"for (const v of JSON.parse(process.argv[2])) {"
" const r = m.resolveIcon(v); out[v] = [r.kind, r.label ?? null]; }"
"console.log(JSON.stringify(out));"
)
values = [
"fa:rocket",
"fa:mug-hot",
"fa:person-dress-burst",
"fa:paw",
"fa:rocket-ship-9000",
"fa:Rocket",
"fa:",
"lucide:dog",
"dog",
"lucide:rocket",
]
result = subprocess.run(
[node, "--input-type=module", "-e", script,
ARTEFACTS[0].as_uri(), json.dumps(values)],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0 and "Unknown file extension" in result.stderr:
pytest.skip("node cannot strip TypeScript types (needs Node 22.18+)")
assert result.returncode == 0, result.stderr
resolved = json.loads(result.stdout)
assert resolved["fa:rocket"] == ["fa", "Rocket"]
assert resolved["fa:mug-hot"] == ["fa", "Mug Hot"]
assert resolved["fa:person-dress-burst"] == ["fa", "Person Dress Burst"]
# Curated entries keep their quick-pick label.
assert resolved["fa:paw"] == ["fa", "Pets"]
assert resolved["lucide:dog"][0] == "lucide"
assert resolved["dog"][0] == "lucide"
for unknown in ("fa:rocket-ship-9000", "fa:Rocket", "fa:", "lucide:rocket"):
assert resolved[unknown] == ["fallback", None], unknown
def test_registry_has_no_remote_url_cdn_or_lucide_runtime() -> None:
for path in (SOURCE, GENERATOR, *ARTEFACTS):
text = path.read_text(encoding="utf-8")
+58 -10
View File
@@ -2,9 +2,10 @@
* Offline generator for the shared quest-icon registry.
*
* Reads the curated names + labels in `curated-icons.json` (the one source of
* truth), pulls each glyph's SVG path data from locally installed packages in
* `admin/node_modules` (lucide-react and @fortawesome/free-solid-svg-icons —
* no network), and writes the same TypeScript module into both bundles:
* truth for the quick-pick lists), pulls each glyph's SVG path data from
* locally installed packages in `admin/node_modules` (lucide-react and
* @fortawesome/free-solid-svg-icons — no network), and writes the same
* TypeScript module into both bundles:
*
* admin/src/icons/registry.generated.ts
* frontend/src/icons/registry.generated.ts
@@ -14,6 +15,14 @@
*
* Lucide circles, rects and lines are converted to path `d` strings so every
* glyph is plain `{ viewBox, paths }` data any renderer can draw.
*
* Besides the curated sets, the module embeds the complete Font Awesome Free
* Solid set (`FA_SOLID_ICONS`). Its source is the installed
* @fortawesome/free-solid-svg-icons package itself: every exported icon
* definition, de-duplicated by canonical `iconName` (the package also exports
* each legacy alias, e.g. faHome for `house`, as the same definition). The
* package carries no human labels, so each label is the kebab name in Title
* Case (`spray-can-sparkles` → "Spray Can Sparkles").
*/
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { createRequire } from "node:module";
@@ -89,6 +98,37 @@ function faExportName(name) {
return "fa" + name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join("");
}
function faGlyph(definition, label) {
const { iconName: name } = definition;
const [width, height, , , pathData] = definition.icon;
const paths = Array.isArray(pathData) ? pathData.filter(Boolean) : [pathData];
return { kind: "fa", key: `fa:${name}`, name, label, viewBox: `0 0 ${width} ${height}`, paths };
}
function titleCase(name) {
return name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
}
/** Every Free Solid glyph, one per canonical name, sorted by name. */
function allSolidGlyphs(faPackage) {
const byName = new Map();
for (const definition of Object.values(faPackage)) {
if (!definition || typeof definition !== "object" || !Array.isArray(definition.icon)) continue;
if (definition.prefix !== "fas") throw new Error(`fa ${definition.iconName}: unexpected prefix ${definition.prefix}`);
const name = definition.iconName;
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) throw new Error(`fa ${name}: not a kebab name`);
const seen = byName.get(name);
if (seen && JSON.stringify(seen.icon) !== JSON.stringify(definition.icon)) {
throw new Error(`fa ${name}: two exports disagree on the glyph`);
}
byName.set(name, definition);
}
if (byName.size === 0) throw new Error("fa: no icons found in @fortawesome/free-solid-svg-icons");
return [...byName.keys()]
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
.map((name) => faGlyph(byName.get(name), titleCase(name)));
}
function checkCurated(list, namespace) {
const seen = new Set();
for (const { name, label } of list) {
@@ -116,11 +156,9 @@ async function buildEntries() {
for (const { name, label } of curated.fa) {
const definition = faPackage[faExportName(name)];
if (!definition || definition.iconName !== name) throw new Error(`fa ${name}: not in @fortawesome/free-solid-svg-icons`);
const [width, height, , , pathData] = definition.icon;
const paths = Array.isArray(pathData) ? pathData.filter(Boolean) : [pathData];
fa.push({ kind: "fa", key: `fa:${name}`, name, label, viewBox: `0 0 ${width} ${height}`, paths });
fa.push(faGlyph(definition, label));
}
return { lucide, fa };
return { lucide, fa, faSolid: allSolidGlyphs(faPackage) };
}
const RESOLVER = `
@@ -130,7 +168,10 @@ export const FALLBACK_ICON: FallbackIcon = { kind: "fallback", key: null };
export const EMOJI_MAX_CODE_POINTS = 8;
const LUCIDE_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(LUCIDE_ICONS.map((icon) => [icon.name, icon]));
const FA_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(FA_ICONS.map((icon) => [icon.name, icon]));
/** The full Free Solid set, with each curated entry (and its quick-pick label) taking precedence. */
const FA_BY_NAME: ReadonlyMap<string, GlyphIcon> = new Map(
[...FA_SOLID_ICONS, ...FA_ICONS].map((icon) => [icon.name, icon]),
);
function isEmojiPayload(text: string): boolean {
const codePoints = [...text];
@@ -142,7 +183,8 @@ function isEmojiPayload(text: string): boolean {
* The renderable icon for a stored definition \`icon\` value.
*
* \`lucide:<name>\` and a legacy bare \`<name>\` resolve against the curated
* Lucide set, \`fa:<name>\` against the curated Font Awesome set, and
* Lucide set, \`fa:<name>\` against the full Font Awesome Free Solid set
* (curated entries keep their quick-pick labels), and
* \`emoji:<grapheme>\` to its text. Like the backend's
* normalize_icon_for_read, nothing here throws: null, empty, an unknown
* namespace or name, or a malformed emoji all resolve to FALLBACK_ICON.
@@ -162,7 +204,7 @@ export function resolveIcon(value: string | null | undefined): ResolvedIcon {
/** The generated module's full text (identical for both bundles). */
export async function renderRegistry() {
const { lucide, fa } = await buildEntries();
const { lucide, fa, faSolid } = await buildEntries();
const source = relative(REPO_ROOT, SOURCE_PATH);
return `// GENERATED by tools/icons/generate.mjs from ${source} — do not edit.
// Regenerate with \`node tools/icons/generate.mjs\` (the drift tests fail if this
@@ -202,7 +244,13 @@ export type ResolvedIcon = GlyphIcon | EmojiIcon | FallbackIcon;
export const LUCIDE_ICONS: readonly GlyphIcon[] = ${JSON.stringify(lucide, null, 2)};
/** The curated Font Awesome quick-pick set. */
export const FA_ICONS: readonly GlyphIcon[] = ${JSON.stringify(fa, null, 2)};
/** Every Font Awesome Free Solid glyph (${faSolid.length}), sorted by name; one entry per line. */
export const FA_SOLID_ICONS: readonly GlyphIcon[] = [
${faSolid.map((icon) => JSON.stringify(icon)).join(",\n")}
];
${RESOLVER}`;
}