Shared icon registry: curated Lucide + Font Awesome, namespaced resolver

tools/icons/curated-icons.json is the one hand-edited list (the 36 curated
Lucide icons plus 22 Font Awesome Free solid chore icons, name + label).
tools/icons/generate.mjs reads it, takes path data offline from the pinned
lucide-react and @fortawesome/free-solid-svg-icons packages, converts Lucide
circles/rects/lines to paths, and writes the same plain-data module (viewBox
+ path strings, emoji text, resolveIcon parser with a fallback entry) into
admin/src/icons and frontend/src/icons. No CDN, no runtime fetch.

The admin Definitions tab now draws its Lucide set from the registry
(lucide-react becomes a generator-only dev dependency); the panel quest log
renders lucide:, fa: and emoji: icons, falling back to the star. Drift tests
in Vitest and pytest fail if the source or either artefact is edited alone.

Task: 03ef0bb3-8228-4be6-bb73-ee8bb72c2f0f

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 07:50:32 -04:00
co-authored by Claude Opus 5.5
parent 60d1594136
commit 14fe912848
14 changed files with 3802 additions and 975 deletions
+94
View File
@@ -0,0 +1,94 @@
"""The shared quest-icon registry: one curated source, two identical artefacts.
``tools/icons/curated-icons.json`` is the only hand-edited list; the offline
generator ``tools/icons/generate.mjs`` writes the same module into the admin
PWA and the panel bundle. The admin Vitest suite regenerates and compares
byte-for-byte; these checks need no Node for the core guarantees.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent
SOURCE = REPO_ROOT / "tools" / "icons" / "curated-icons.json"
GENERATOR = REPO_ROOT / "tools" / "icons" / "generate.mjs"
ARTEFACTS = (
REPO_ROOT / "admin" / "src" / "icons" / "registry.generated.ts",
REPO_ROOT / "frontend" / "src" / "icons" / "registry.generated.ts",
)
PANEL_BUNDLE = (
REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js"
)
def _curated() -> dict[str, list[dict[str, str]]]:
return json.loads(SOURCE.read_text(encoding="utf-8"))
def _generated_set(text: str, const: str) -> list[dict]:
match = re.search(
rf"export const {const}: readonly GlyphIcon\[\] = (\[.*?\n\]);",
text,
re.DOTALL,
)
assert match, f"{const} not found in the generated registry"
return json.loads(match.group(1))
def test_both_artefacts_are_byte_identical() -> None:
admin, frontend = (path.read_bytes() for path in ARTEFACTS)
assert admin == frontend
def test_artefacts_carry_exactly_the_curated_names_and_labels() -> None:
curated = _curated()
text = ARTEFACTS[0].read_text(encoding="utf-8")
for namespace, const in (("lucide", "LUCIDE_ICONS"), ("fa", "FA_ICONS")):
entries = _generated_set(text, const)
assert [
{"name": e["name"], "label": e["label"]} for e in entries
] == curated[namespace]
for entry in entries:
assert entry["kind"] == namespace
assert entry["key"] == f"{namespace}:{entry['name']}"
assert re.fullmatch(r"0 0 \d+ \d+", entry["viewBox"])
assert entry["paths"] and all(entry["paths"])
assert len(curated["lucide"]) == 36
assert 15 <= len(curated["fa"]) <= 25
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")
assert not re.search(r"https?://", text, re.IGNORECASE), path
assert not re.search(
r"\bcdn\b|jsdelivr|unpkg|cdnjs|kit\.fontawesome", text, re.IGNORECASE
), path
assert "data-lucide" not in text, path
def test_panel_bundle_embeds_the_registry() -> None:
bundle = PANEL_BUNDLE.read_text(encoding="utf-8")
assert "fa:broom" in bundle
assert "lucide:dog" in bundle
assert "data-lucide" not in bundle
def test_generator_reproduces_the_committed_artefacts() -> None:
node = shutil.which("node")
if node is None or not (REPO_ROOT / "admin" / "node_modules").is_dir():
pytest.skip("needs node and admin/node_modules (npm ci in admin/)")
result = subprocess.run(
[node, str(GENERATOR), "--check"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr