@hadoku/themes 0.0.0-stage → 5.12.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE-lucide +43 -0
  3. package/README.md +433 -2
  4. package/THEME_USAGE_GUIDE.md +308 -0
  5. package/dist/HadokuThemeRoot.d.ts +53 -0
  6. package/dist/HadokuThemeRoot.js +66 -0
  7. package/dist/ThemeIcons.d.ts +31 -0
  8. package/dist/ThemeIcons.js +91 -0
  9. package/dist/effects.d.ts +25 -0
  10. package/dist/effects.js +25 -0
  11. package/dist/icons/Icon.d.ts +36 -0
  12. package/dist/icons/Icon.js +89 -0
  13. package/dist/icons/index.d.ts +61 -0
  14. package/dist/icons/index.js +87 -0
  15. package/dist/icons/registry.generated.d.ts +158 -0
  16. package/dist/icons/registry.generated.js +446 -0
  17. package/dist/index.d.ts +82 -0
  18. package/dist/index.js +169 -0
  19. package/dist/metadata.d.ts +35 -0
  20. package/dist/metadata.js +108 -0
  21. package/dist/platform/index.d.ts +94 -0
  22. package/dist/platform/index.js +149 -0
  23. package/dist/platform/usePlatform.d.ts +29 -0
  24. package/dist/platform/usePlatform.js +65 -0
  25. package/dist/prefsApiBase.d.ts +37 -0
  26. package/dist/prefsApiBase.js +40 -0
  27. package/dist/theme-types.d.ts +34 -0
  28. package/dist/theme-types.js +1 -0
  29. package/dist/themeContext.d.ts +62 -0
  30. package/dist/themeContext.js +70 -0
  31. package/dist/themePrefs.d.ts +29 -0
  32. package/dist/themePrefs.js +48 -0
  33. package/dist/useRefElement.d.ts +19 -0
  34. package/dist/useRefElement.js +52 -0
  35. package/dist/useTheme.d.ts +46 -0
  36. package/dist/useTheme.js +216 -0
  37. package/package.json +83 -4
  38. package/scripts/audit-tokens.mjs +126 -0
  39. package/scripts/check-contrast.mjs +24 -0
  40. package/scripts/check-docs.mjs +53 -0
  41. package/scripts/check-icons.mjs +473 -0
  42. package/scripts/check-usage.mjs +297 -0
  43. package/scripts/codemod-icons.mjs +396 -0
  44. package/scripts/generate-icons.mjs +199 -0
  45. package/scripts/generate-preview.mjs +374 -0
  46. package/scripts/generate-tailwind-colors.mjs +109 -0
  47. package/scripts/lib/color.mjs +184 -0
  48. package/scripts/lib/contract.d.mts +58 -0
  49. package/scripts/lib/contract.mjs +348 -0
  50. package/scripts/lib/derive.mjs +324 -0
  51. package/scripts/lib/icon-registry.mjs +92 -0
  52. package/scripts/lib/parse-themes.mjs +19 -0
  53. package/scripts/lib/tokens.mjs +188 -0
  54. package/scripts/normalize-tokens.mjs +161 -0
  55. package/src/advanced.css +218 -0
  56. package/src/icons/Icon.tsx +151 -0
  57. package/src/icons/emoji-map.json +202 -0
  58. package/src/icons/index.ts +151 -0
  59. package/src/icons/registry.generated.ts +455 -0
  60. package/src/icons/sources.json +150 -0
  61. package/src/icons.css +145 -0
  62. package/src/style.css +1421 -0
  63. package/src/tailwind-colors.css +88 -0
  64. package/src/tailwind-integration.css +49 -0
@@ -0,0 +1,473 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gate: icons come from the registry, and only from the registry.
4
+ *
5
+ * Two checks, mirroring the two ways the enforced set gets bypassed:
6
+ *
7
+ * 1. An icon name that is not in the registry. TS consumers get this from the
8
+ * `IconName` union, but the frontpage POCs are Astro and one is Qwik, and the
9
+ * catalogue itself is JSON — none of which typecheck against our union. A name
10
+ * typo'd in categories.json renders nothing, silently.
11
+ * 2. A raw emoji used AS AN ICON. This is the rule the module exists to retire:
12
+ * platform emoji fonts render the same codepoint differently per OS, and there
13
+ * is no CSS fix. Only genuinely UI-facing positions are flagged — an emoji in a
14
+ * console.log, a code comment or a markdown doc is not an icon and is ignored,
15
+ * because a gate that fires on prose gets switched off.
16
+ *
17
+ * node themes/scripts/check-icons.mjs [paths...] (default: cwd)
18
+ * --emoji-only / --names-only run just one of the checks
19
+ * --list print the registry and exit
20
+ *
21
+ * Exits non-zero on any finding.
22
+ */
23
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
24
+ import { join, extname, relative, resolve, dirname, sep } from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+ import { loadIconRegistry, MISSING_REGISTRY_MESSAGE } from './lib/icon-registry.mjs'
27
+
28
+ const here = dirname(fileURLToPath(import.meta.url))
29
+
30
+ // Prefer the authored .ts (no build needed in this workspace), fall back to the built
31
+ // registry (the only tree a consumer install is guaranteed to carry). See lib/icon-registry.mjs.
32
+ const {
33
+ iconNames: ICON_NAMES,
34
+ emojiMap: EMOJI_MAP_RAW,
35
+ ambiguous: AMBIGUOUS_RAW,
36
+ } = await loadIconRegistry()
37
+
38
+ if (!ICON_NAMES.size) {
39
+ console.error(`check-icons: ${MISSING_REGISTRY_MESSAGE}`)
40
+ process.exit(1)
41
+ }
42
+ if (process.argv.includes('--list')) {
43
+ console.log([...ICON_NAMES].sort().join('\n'))
44
+ process.exit(0)
45
+ }
46
+
47
+ const args = process.argv.slice(2)
48
+ const roots = args.filter(a => !a.startsWith('--'))
49
+ const targets = roots.length ? roots : [process.cwd()]
50
+ const emojiOnly = args.includes('--emoji-only')
51
+ const namesOnly = args.includes('--names-only')
52
+
53
+ // `.claude` holds git worktrees — separate checkouts that lint themselves.
54
+ const SKIP_DIRS = new Set([
55
+ 'node_modules', '.git', 'dist', 'build', 'out', '.next', '.astro', '.claude',
56
+ 'playwright-report', 'test-results', '.profiler', '.wrangler', 'coverage',
57
+ 'vendor', 'third_party', '__pycache__', '.venv', 'venv',
58
+ // Vendored third-party trees. hadoku-conjure carries ComfyUI extensions under
59
+ // backend/custom_nodes and a Python env with site-packages; 14 of its findings
60
+ // were somebody else's emoji in somebody else's code, which we neither can nor
61
+ // should edit. `.venv` alone did not cover it — that env is `.venv-torch2110-backup`.
62
+ 'custom_nodes', 'site-packages', 'bower_components', 'Pods', '.tox', '.gradle',
63
+ // Scraped/generated payload dumps. ArchiveBot writes Discord archives to
64
+ // Output/<guild>/<channel>/archive_*.json, and every reaction in them is an
65
+ // `"emoji":` field — 320 findings that are somebody's chat history, not icons.
66
+ // A directory literally named Output holds output, never authored UI.
67
+ 'Output', 'output', 'outputs'
68
+ ])
69
+
70
+ /** Virtualenv directories are named freely (`.venv-torch2110-backup`, `env39`). */
71
+ const VENV_DIR = /^\.?(venv|virtualenv|env)[-_.0-9a-z]*$/i
72
+
73
+ /**
74
+ * Generated output. An emoji here is a copy of one in some source file, and
75
+ * "fixing" it is worse than useless: the edit is erased by the next build while
76
+ * the real occurrence stays put. hadoku_site alone carries ~76 of these in
77
+ * `public/mf/*` (bundled micro-frontends) and `public/v3` — a quarter of the
78
+ * ecosystem's raw findings, all of them phantoms.
79
+ */
80
+ const GENERATED_PATH =
81
+ /(^|\/)(public\/(mf|v\d+)|\.output|\.vercel|storybook-static|bundle)(\/|$)|\.(min|bundle|generated)\.[a-z]+$/
82
+
83
+ /**
84
+ * Path segments that hold code with no DOM to render into. Applies to SCRIPT
85
+ * files only — never to markup — because the distinction is "can this string
86
+ * ever become an icon", and in a worker it cannot.
87
+ *
88
+ * This is what stops the gate demanding SVGs inside Discord webhook payloads.
89
+ * monitoring-api posts `🚨 **runners** on …` to a channel; Discord renders emoji
90
+ * and cannot render an inline SVG, so the emoji there is not a stand-in for an
91
+ * icon, it IS the correct output. Same for CLI banners under `scripts/`.
92
+ *
93
+ * Deliberately NOT extension-blind: `personal-dataplatform/server/.../static/
94
+ * index.html` is real UI that happens to live under `server/`, and it must keep
95
+ * being checked. A `.html` file is a UI file wherever it sits.
96
+ */
97
+ const NON_UI_CODE_DIR =
98
+ /(^|\/)(workers?|services?|server|scripts?|functions|cli|bin|migrations|e2e|tests?|__tests__|output|outputs|formatters?)(\/|$)/
99
+
100
+ /**
101
+ * Test files, matched by NAME rather than by folder. `spec` is deliberately NOT a
102
+ * skipped directory: hadoku_site keeps its icon CATALOGUE in `spec/categories.json`,
103
+ * the single most important file in this whole migration, and a `spec/` rule
104
+ * silently excused all three copies of it.
105
+ */
106
+ const TEST_FILE = /\.(spec|test)\.[cm]?[jt]sx?$/
107
+
108
+ const MARKUP_EXT = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html'])
109
+ const CONFIG_EXT = new Set(['.json'])
110
+ const SCRIPT_EXT = new Set(['.ts', '.js', '.mjs', '.cjs'])
111
+
112
+ // A pictographic run, including ZWJ sequences, skin tones and keycaps.
113
+ const EMOJI = /(?:\p{RI}\p{RI}|[#*0-9]️?⃣|\p{Extended_Pictographic}(?:️|[\u{1F3FB}-\u{1F3FF}])*(?:‍\p{Extended_Pictographic}(?:️|[\u{1F3FB}-\u{1F3FF}])*)*)/gu
114
+
115
+ /**
116
+ * In an ICON POSITION, any pictograph is an icon — emoji presentation or not.
117
+ *
118
+ * Requiring `\p{Emoji_Presentation}` (or a VS16) here was wrong and let real icons
119
+ * through: `⚙` U+2699, `▶` U+25B6, `✏` U+270F and `⚠` U+26A0 are all
120
+ * Extended_Pictographic with TEXT presentation by default, and this repo shipped a
121
+ * bare `⚙` as its board-settings button. They render from the platform emoji font
122
+ * just the same, which is the whole problem.
123
+ *
124
+ * This stays safe from false positives because it is only ever applied to icon
125
+ * positions, and because the symbols that legitimately appear as bare text glyphs
126
+ * — `✓` U+2713, `×` U+00D7, `↺` U+21BA, `→` U+2192 — are not Extended_Pictographic
127
+ * at all, so they never reach this predicate.
128
+ */
129
+ const isEmoji = seq => /\p{Extended_Pictographic}|\p{RI}\p{RI}|⃣/u.test(seq)
130
+
131
+ /**
132
+ * CANONICAL emoji -> icon name, so a finding carries its own answer.
133
+ *
134
+ * A gate that says "use a name from the registry" leaves 217 decisions on the
135
+ * floor, and twelve repos migrating independently would make them twelve
136
+ * different ways. With the map, each finding is an edit rather than a decision —
137
+ * and the mapping is settled once, here.
138
+ */
139
+ const EMOJI_MAP = new Map()
140
+ if (EMOJI_MAP_RAW) {
141
+ const unknown = []
142
+ for (const [emoji, name] of Object.entries(EMOJI_MAP_RAW)) {
143
+ if (emoji.startsWith('$')) continue
144
+ if (!ICON_NAMES.has(name)) unknown.push(`${emoji} -> ${name}`)
145
+ EMOJI_MAP.set(stripVs(emoji), name)
146
+ }
147
+ if (unknown.length) {
148
+ console.error('check-icons: emoji-map.json points at icons that do not exist:')
149
+ for (const u of unknown) console.error(` ${u}`)
150
+ process.exit(1)
151
+ }
152
+ }
153
+
154
+ /** Variation selectors and skin tones are presentation, not identity. */
155
+ function stripVs(s) {
156
+ return [...s].filter(c => {
157
+ const p = c.codePointAt(0)
158
+ return p !== 0xfe0f && p !== 0xfe0e && !(p >= 0x1f3fb && p <= 0x1f3ff)
159
+ }).join('')
160
+ }
161
+
162
+ /**
163
+ * Glyphs whose single mapping is only right in one context — see `$comment_ambiguous`
164
+ * in emoji-map.json. Printing the suggestion bare presents one arm of a mirrored pair
165
+ * as settled, which is how `◀ Pick A` / `Pick B ▶` shipped as a chevron facing a
166
+ * filled triangle. The caveat rides along with the answer instead.
167
+ */
168
+ const AMBIGUOUS = new Map(
169
+ Object.entries(AMBIGUOUS_RAW ?? {}).map(([e, note]) => [stripVs(e), note])
170
+ )
171
+
172
+ /** The suggestion appended to a finding, when we know the answer. */
173
+ function suggest(raw) {
174
+ const first = [...raw.matchAll(EMOJI)].map(m => m[0]).filter(isEmoji)[0]
175
+ if (!first) return ''
176
+ const key = stripVs(first)
177
+ const name = EMOJI_MAP.get(key)
178
+ if (!name) return ''
179
+ const caveat = AMBIGUOUS.get(key)
180
+ return `\n → <Icon name="${name}" /> (getIconSvg('${name}'))` +
181
+ (caveat ? `\n ⚠ ${first} needs judgement: ${caveat}` : '')
182
+ }
183
+
184
+
185
+ function* walk(dir) {
186
+ let entries
187
+ try {
188
+ entries = readdirSync(dir, { withFileTypes: true })
189
+ } catch {
190
+ return
191
+ }
192
+ for (const e of entries) {
193
+ if (SKIP_DIRS.has(e.name)) continue
194
+ if (e.isDirectory() && VENV_DIR.test(e.name)) continue
195
+ const full = join(dir, e.name)
196
+ if (e.isSymbolicLink()) continue
197
+ if (e.isDirectory()) yield* walk(full)
198
+ else yield full
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Opt out one line at a time, same convention as check-usage:
204
+ *
205
+ * {/* check-icons-disable-next-line *\/}
206
+ * <span>🎉</span>
207
+ *
208
+ * The case this exists for is an emoji that is genuinely emoji — an emoji picker,
209
+ * a reaction the user chose, a Discord message composed in the browser. Those are
210
+ * content, not iconography, and no registry entry can stand in for them.
211
+ */
212
+ const DISABLE_NEXT = /(?:\/\*|\/\/|<!--|\{\s*\/\*)\s*check-icons-disable-next-line/
213
+
214
+ const findings = []
215
+ const seen = new Set()
216
+ /** One finding per file:line — the rules deliberately overlap, the report should not. */
217
+ const add = (file, line, rule, message) => {
218
+ const key = `${file}:${line}`
219
+ if (seen.has(key)) return
220
+ seen.add(key)
221
+ findings.push({ file, line, rule, message })
222
+ }
223
+
224
+ /** An emoji here is decoration or data, not an icon. */
225
+ const isNonUiLine = line => {
226
+ const l = line.trim()
227
+ return (
228
+ /^(\/\/|\/\*|\*|#|<!--)/.test(l) ||
229
+ // `logger.*` matters as much as `console.*`: the hadoku ecosystem routes all
230
+ // logging through @wolffm/logger, so console calls are the exception, not the rule.
231
+ /\b(console|logger|log)\s*\.\s*(log|warn|error|info|debug|table|trace|group)\b/.test(l) ||
232
+ /process\.stdout|\bprint\(/.test(l)
233
+ )
234
+ }
235
+
236
+ /**
237
+ * Where a `//` line comment starts, or the line length. `isNonUiLine` only catches
238
+ * lines that BEGIN with a marker, so a TRAILING comment is still "code" to it —
239
+ * and pygmalion's icons.tsx annotates its whole registry that way
240
+ * (`export const WarnIcon = TriangleAlert // ⚠`). Prose about an icon is not an icon.
241
+ * Quote-aware, so a `//` inside a string is not mistaken for a comment.
242
+ */
243
+ const codeEndOf = line => {
244
+ let quote = null
245
+ for (let k = 0; k < line.length; k++) {
246
+ const c = line[k]
247
+ if (quote) {
248
+ if (c === '\\') k++
249
+ else if (c === quote) quote = null
250
+ continue
251
+ }
252
+ if (c === "'" || c === '"' || c === '`') { quote = c; continue }
253
+ if (c === '/' && line[k + 1] === '/') return k
254
+ }
255
+ return line.length
256
+ }
257
+
258
+ /** JSON keys whose value is rendered as an icon. */
259
+ const ICON_KEY = /"(icon|emoji|glyph|symbol)"\s*:\s*"([^"]*)"/g
260
+ /** The same idea in JS/TS object literals and JSX props. */
261
+ const ICON_PROP = /\b(icon|glyph|emoji)\s*[=:]\s*(?:"([^"]*)"|'([^']*)'|\{?\s*['"]([^'"]*)['"])/g
262
+ /** Registry lookups whose name we can validate. */
263
+ const ICON_USE = /(?:getIconSvg\(\s*['"]([a-z0-9-]+)['"]|<Icon\b[^>]*?\bname\s*=\s*['"]([a-z0-9-]+)['"])/g
264
+
265
+ let scanned = 0
266
+ for (const target of targets) {
267
+ const root = resolve(target)
268
+ if (!existsSync(root)) {
269
+ console.error(`check-icons: no such path: ${target}`)
270
+ process.exit(1)
271
+ }
272
+ for (const file of walk(root)) {
273
+ const ext = extname(file)
274
+ const isMarkup = MARKUP_EXT.has(ext)
275
+ const isConfig = CONFIG_EXT.has(ext)
276
+ const isScript = SCRIPT_EXT.has(ext)
277
+ const isCode = isMarkup || isScript
278
+ if (!isMarkup && !isConfig && !isCode) continue
279
+
280
+ const posix = file.split(sep).join('/')
281
+ if (GENERATED_PATH.test(posix)) continue
282
+ // Server/CLI code renders no DOM, so nothing in it is an icon. Markup is
283
+ // exempt from this rule — a .html under server/ is still a page.
284
+ if (!isMarkup && NON_UI_CODE_DIR.test(posix)) continue
285
+ if (TEST_FILE.test(posix)) continue
286
+
287
+ let st
288
+ try {
289
+ st = statSync(file)
290
+ } catch {
291
+ continue
292
+ }
293
+ if (st.size > 2 * 1024 * 1024) continue
294
+
295
+ let text
296
+ try {
297
+ text = readFileSync(file, 'utf8')
298
+ } catch {
299
+ continue
300
+ }
301
+ scanned++
302
+ const rel = relative(process.cwd(), file)
303
+ const lines = text.split('\n')
304
+ const waived = new Set()
305
+ lines.forEach((line, i) => {
306
+ if (DISABLE_NEXT.test(line)) waived.add(i + 2)
307
+ })
308
+
309
+ // Track BLOCK comments across lines. `isNonUiLine` only sees a line that
310
+ // STARTS with a marker, so the middle of a `{/* … */}` block reads as code —
311
+ // and personal-dataplatform's VodBrowser has a comment explaining that it
312
+ // used to render "👎 -3", which the gate then reported as an icon. Prose
313
+ // about an emoji is not an emoji.
314
+ let inBlockComment = false
315
+
316
+ for (let i = 0; i < lines.length; i++) {
317
+ if (waived.has(i + 1)) continue
318
+ const opens = (lines[i].match(/\/\*/g) || []).length
319
+ const closes = (lines[i].match(/\*\//g) || []).length
320
+ const startedInside = inBlockComment
321
+ if (opens > closes) inBlockComment = true
322
+ else if (closes > opens) inBlockComment = false
323
+ if (startedInside) continue
324
+ const line = lines[i]
325
+ const no = i + 1
326
+
327
+ // ── check 2: raw emoji in an icon position ──────────────────────────────
328
+ if (!namesOnly && !isNonUiLine(line)) {
329
+ for (const re of [ICON_KEY, ICON_PROP]) {
330
+ re.lastIndex = 0
331
+ let m
332
+ while ((m = re.exec(line)) !== null) {
333
+ const value = m[2] ?? m[3] ?? m[4] ?? ''
334
+ const hit = [...value.matchAll(EMOJI)].map(x => x[0]).filter(isEmoji)
335
+ if (hit.length) {
336
+ add(
337
+ rel, no, 'raw-emoji-icon',
338
+ `${m[1]}: "${value}" is a raw emoji. Platform emoji fonts render it ` +
339
+ `differently per OS. Use a name from the @wolffm/themes registry.` +
340
+ suggest(value)
341
+ )
342
+ }
343
+ }
344
+ }
345
+ if (isMarkup || isCode) {
346
+ // A string literal that is ONLY emoji — `'⏳'`, `icon={'📋'}`, ternary arms.
347
+ // Nothing but an icon is ever written this way.
348
+ // Cap raised from 24 to 200 for MARKUP only. At 24, tenhands'
349
+ // `🔄 Refresh Workflows (${repos.length})` slipped through — a template
350
+ // literal with interpolation is still a label with an icon on the front.
351
+ // Scoped to markup on purpose: vibecop's src/output/*.ts formatters build
352
+ // GitHub issue markdown, where an emoji is the correct output and an SVG
353
+ // is impossible, exactly like the Discord payloads in monitoring-api.
354
+ const maxLen = isMarkup ? 200 : 24
355
+ const STR = new RegExp(`(['\"\`])([^'\"\`\\n]{1,${maxLen}})\\1`, 'g')
356
+ for (const m of line.matchAll(STR)) {
357
+ const raw = m[2].trim()
358
+ if (!raw) continue
359
+ const hit = [...raw.matchAll(EMOJI)].map(x => x[0]).filter(isEmoji)
360
+ if (!hit.length) continue
361
+ if (hit.join('') === raw) {
362
+ add(
363
+ rel, no, 'raw-emoji-icon',
364
+ `"${raw}" is a bare emoji glyph. Use <Icon name="..."/> or getIconSvg().` + suggest(raw)
365
+ )
366
+ } else if (raw.startsWith(hit[0]) && /^\s/.test(raw.slice(hit[0].length))) {
367
+ // `'🗑️ Delete Board'` — an emoji prefixing a label is an icon plus text.
368
+ add(
369
+ rel, no, 'raw-emoji-icon',
370
+ `"${raw}" prefixes a label with a raw emoji. Render <Icon name="..."/> ` +
371
+ `beside the text instead.` + suggest(raw)
372
+ )
373
+ }
374
+ }
375
+ }
376
+ // An emoji LEADING a text run — `📅 Scheduled on Discord:` — reads as an
377
+ // icon with a label, and is how most of these are actually written. The
378
+ // only-emoji rules below miss it entirely, which is why hadoku-meet
379
+ // reported clean while shipping two of them.
380
+ if (isMarkup) {
381
+ // The lookahead includes `{`: `<div>👤 {pr.author}</div>` is the single
382
+ // most common shape of "icon then value" in this codebase, and without
383
+ // it the rule only fired when the label happened to start with a
384
+ // letter. tenhands shipped four that way (👤 💬 📝 🕐) with the gate
385
+ // reporting the repo clean.
386
+ for (const m of line.matchAll(/(^|>|\{' '\}|\{" "\})(\s*)([^\s<>{}]+)\s+(?=[A-Za-z({])/g)) {
387
+ const lead = m[3]
388
+ const hit = [...lead.matchAll(EMOJI)].map(x => x[0]).filter(isEmoji)
389
+ if (hit.length && hit.join('') === lead) {
390
+ add(
391
+ rel, no, 'raw-emoji-icon',
392
+ `"${lead}" leads a text run as an icon. Render <Icon name="..."/> ` +
393
+ `beside the text instead.` + suggest(lead)
394
+ )
395
+ }
396
+ }
397
+ }
398
+
399
+ // An emoji CLOSING a text run — `<b>{name}</b> wins ▶`. Every rule above
400
+ // anchors on what comes BEFORE the glyph, so one sitting AFTER its label was
401
+ // invisible: pygmalion reported clean across 268 files while shipping a raw
402
+ // `▶` in BakeoffReview, and the codemod could not see it either.
403
+ //
404
+ // Anchored on whitespace before and `<` or end-of-line after, and stopped at
405
+ // a trailing comment, which together keep it out of strings, attributes and
406
+ // annotations.
407
+ if (isMarkup) {
408
+ const end = codeEndOf(line)
409
+ for (const m of line.matchAll(/(\s)([^\s<>{}'"`]+)(\s*)(?=<|$)/g)) {
410
+ if (m.index + m[1].length >= end) continue
411
+ const tail = m[2]
412
+ const hit = [...tail.matchAll(EMOJI)].map(x => x[0]).filter(isEmoji)
413
+ if (hit.length && hit.join('') === tail) {
414
+ add(
415
+ rel, no, 'raw-emoji-icon',
416
+ `"${tail}" closes a text run as an icon. Render <Icon name="..."/> ` +
417
+ `beside the text instead.` + suggest(tail)
418
+ )
419
+ }
420
+ }
421
+ }
422
+ // Emoji standing alone as a JSX text node, on its own line or inline.
423
+ if (isMarkup) {
424
+ const bare = line.trim()
425
+ const inline = [...line.matchAll(/>\s*([^<>{}\s]{1,24})\s*</g)].map(m => m[1].trim())
426
+ for (const candidate of [bare, ...inline]) {
427
+ if (!candidate) continue
428
+ const hit = [...candidate.matchAll(EMOJI)].map(x => x[0]).filter(isEmoji)
429
+ if (hit.length && hit.join('') === candidate) {
430
+ add(
431
+ rel, no, 'raw-emoji-icon',
432
+ `"${candidate}" is rendered as a bare emoji glyph. Use <Icon name="..."/> ` +
433
+ `or getIconSvg() from @wolffm/themes.` + suggest(candidate)
434
+ )
435
+ }
436
+ }
437
+ }
438
+ }
439
+
440
+ // ── check 1: a name that is not in the registry ─────────────────────────
441
+ if (!emojiOnly) {
442
+ ICON_USE.lastIndex = 0
443
+ let m
444
+ while ((m = ICON_USE.exec(line)) !== null) {
445
+ const name = m[1] ?? m[2]
446
+ if (!ICON_NAMES.has(name)) {
447
+ add(
448
+ rel, no, 'unknown-icon',
449
+ `"${name}" is not in the icon registry. Add it to ` +
450
+ `themes/src/icons/sources.json and run \`pnpm run generate:icons\`.`
451
+ )
452
+ }
453
+ }
454
+ }
455
+ }
456
+ }
457
+ }
458
+
459
+ if (findings.length) {
460
+ const byRule = findings.reduce((a, f) => ((a[f.rule] = (a[f.rule] || 0) + 1), a), {})
461
+ console.error(`check-icons: FAILED — ${findings.length} finding(s) in ${scanned} files\n`)
462
+ for (const f of findings) {
463
+ console.error(` ${f.file}:${f.line}`)
464
+ console.error(` [${f.rule}] ${f.message}`)
465
+ }
466
+ console.error(
467
+ `\n ${Object.entries(byRule).map(([k, v]) => `${k}: ${v}`).join(', ')}` +
468
+ `\n Registry has ${ICON_NAMES.size} icons — list them with \`--list\`.`
469
+ )
470
+ process.exit(1)
471
+ }
472
+
473
+ console.log(`check-icons: OK — ${scanned} files, ${ICON_NAMES.size} icons in the registry`)