Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
136 lines
5.3 KiB
TypeScript
136 lines
5.3 KiB
TypeScript
/**
|
|
* The icons a definition's `icon` may name (ADMIN-SPEC §3.3): the curated
|
|
* Lucide set, any icon in the full Font Awesome Free Solid set, or an emoji.
|
|
* The Font Awesome quick picks (`FA_DEFINITION_ICONS`) are only a curated
|
|
* shortcut list; `fa:<name>` resolves against the full set
|
|
* (`FA_SOLID_ICONS`). The names, labels and path data come from the shared
|
|
* icon registry (`src/icons/registry.generated.ts`, generated by
|
|
* `tools/icons/generate.mjs` from `tools/icons/curated-icons.json` and the
|
|
* installed Free Solid package) — no CDN, no runtime fetch. The stored value
|
|
* is namespaced — `lucide:<name>`, `fa:<name>` or `emoji:<grapheme>` — and a
|
|
* legacy bare `<kebab-name>` still names the same Lucide entry.
|
|
*/
|
|
import { GlyphSvg } from "../icons/GlyphSvg";
|
|
import {
|
|
FA_ICONS,
|
|
FA_SOLID_ICONS,
|
|
LUCIDE_ICONS,
|
|
resolveIcon,
|
|
type GlyphIcon,
|
|
} from "../icons/registry.generated";
|
|
import { TaskGlyph } from "./glyphs";
|
|
|
|
export type DefinitionIconEntry = GlyphIcon;
|
|
|
|
export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = LUCIDE_ICONS;
|
|
|
|
export const FA_DEFINITION_ICONS: readonly DefinitionIconEntry[] = FA_ICONS;
|
|
|
|
const LUCIDE_PREFIX = "lucide:";
|
|
const FA_PREFIX = "fa:";
|
|
const EMOJI_PREFIX = "emoji:";
|
|
|
|
/** The canonical stored key for a Lucide choice, e.g. `lucide:dog`. */
|
|
export function lucideIconKey(name: string): string {
|
|
return `${LUCIDE_PREFIX}${name}`;
|
|
}
|
|
|
|
/** The canonical stored key for a Font Awesome choice, e.g. `fa:dog`. */
|
|
export function faIconKey(name: string): string {
|
|
return `${FA_PREFIX}${name}`;
|
|
}
|
|
|
|
/**
|
|
* The stored key for typed emoji text, e.g. `emoji:🐶`. The raw text — not
|
|
* trimmed, as the backend rejects any whitespace — must pass the registry's
|
|
* emoji check (1-8 code points, no whitespace, control characters or `<`,
|
|
* `>`, `&`), else null. Only an empty field means "no icon".
|
|
*/
|
|
export function emojiIconKey(text: string): string | null {
|
|
const key = `${EMOJI_PREFIX}${text}`;
|
|
return resolveIcon(key).kind === "emoji" ? key : null;
|
|
}
|
|
|
|
/**
|
|
* The Lucide name an icon value refers to: `lucide:dog` and a legacy bare
|
|
* `dog` both give `dog`. Any other namespace (`fa:`, `emoji:`, unknown) and
|
|
* null give null — the admin has no Lucide entry for them.
|
|
*/
|
|
export function normalizeDefinitionIconName(value: string | null): string | null {
|
|
if (value === null) return null;
|
|
if (value.startsWith(LUCIDE_PREFIX)) return value.slice(LUCIDE_PREFIX.length);
|
|
return value.includes(":") ? null : value;
|
|
}
|
|
|
|
/** The Font Awesome name an `fa:<name>` value refers to; anything else gives null. */
|
|
export function faIconName(value: string | null): string | null {
|
|
return value?.startsWith(FA_PREFIX) ? value.slice(FA_PREFIX.length) : null;
|
|
}
|
|
|
|
/**
|
|
* The Font Awesome Free Solid icons whose name or label contains `query`
|
|
* (trimmed, case-insensitive): an exact name first, then name-prefix
|
|
* matches, then the rest, each in registry (name) order. An empty query
|
|
* gives no results — the curated quick picks cover that case.
|
|
*/
|
|
export function searchFaIcons(query: string): DefinitionIconEntry[] {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return [];
|
|
const rank = (icon: DefinitionIconEntry) => (icon.name === q ? 0 : icon.name.startsWith(q) ? 1 : 2);
|
|
return FA_SOLID_ICONS.filter(
|
|
(icon) => icon.name.includes(q) || icon.label.toLowerCase().includes(q),
|
|
).sort((a, b) => rank(a) - rank(b));
|
|
}
|
|
|
|
/**
|
|
* The stored key for a typed Font Awesome name, e.g. `Dog` → `fa:dog`: the
|
|
* trimmed, lower-cased text must be the exact name of a Free Solid icon,
|
|
* else null (a misspelling, a partial search, or a Pro/Brands name).
|
|
*/
|
|
export function faTypedIconKey(text: string): string | null {
|
|
const key = faIconKey(text.trim().toLowerCase());
|
|
return resolveIcon(key).kind === "fa" ? key : null;
|
|
}
|
|
|
|
/**
|
|
* Why Font Awesome search text cannot be saved as it stands: it matches
|
|
* icons but names none of them, or it matches nothing in the Free Solid set.
|
|
*/
|
|
export function faTextProblem(text: string): string {
|
|
return searchFaIcons(text).length > 0
|
|
? "Select a Font Awesome icon from the results, or type its full name."
|
|
: `“${text.trim()}” is not a Font Awesome Free Solid icon.`;
|
|
}
|
|
|
|
/** The emoji text an `emoji:<grapheme>` value holds; anything else gives "". */
|
|
export function emojiIconText(value: string | null): string {
|
|
return value?.startsWith(EMOJI_PREFIX) ? value.slice(EMOJI_PREFIX.length) : "";
|
|
}
|
|
|
|
/**
|
|
* The glyph an icon value resolves to via the shared parser: a curated
|
|
* Lucide entry or any Font Awesome Free Solid icon (quick pick or not);
|
|
* emoji, unknown and null values give null.
|
|
*/
|
|
export function findDefinitionIcon(value: string | null): DefinitionIconEntry | null {
|
|
const icon = resolveIcon(value);
|
|
return icon.kind === "lucide" || icon.kind === "fa" ? icon : null;
|
|
}
|
|
|
|
/**
|
|
* A definition's icon: a Lucide or Font Awesome glyph, an emoji drawn as
|
|
* text, or — for no icon or an unknown value — the generic task glyph.
|
|
*/
|
|
export function DefinitionIcon({ name, size = 17 }: { name: string | null; size?: number }) {
|
|
const icon = resolveIcon(name);
|
|
if (icon.kind === "fallback") return <TaskGlyph size={size} />;
|
|
if (icon.kind === "emoji") {
|
|
return (
|
|
<span className="defs-emoji" style={{ fontSize: size }} aria-hidden="true" data-emoji={icon.text}>
|
|
{icon.text}
|
|
</span>
|
|
);
|
|
}
|
|
return <GlyphSvg icon={icon} size={size} />;
|
|
}
|