@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,297 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gate: theme tokens must exist, and must be paired correctly.
4
+ *
5
+ * Six checks, because each covers a blind spot the others miss:
6
+ *
7
+ * 1. `var(--color-*)` in stylesheets — catches renamed/typo'd tokens.
8
+ * 2. Tailwind colour classes in markup — stylelint only lints .css and never
9
+ * sees `bg-success-bg` inside a .tsx, which is how hadoku_site shipped
10
+ * white buttons.
11
+ * 3. `var(--x, fallback)` fallbacks — a fallback renders the hardcoded
12
+ * value, so a wrong token looks correct in the default light theme and
13
+ * breaks in the other 17. This is the silent-failure vector, and the
14
+ * stylelint rule everyone reaches for ignores it by design.
15
+ * 4. Layered `style.css` imports — putting it in a cascade layer makes
16
+ * every colour resolve to nothing.
17
+ * 5. The tint anti-pattern — `--color-<f>` as text on
18
+ * `--color-<f>-bg`. Both tokens exist, so nothing else flags it, but the
19
+ * pair fails AA in 62 of 90 theme/family combinations.
20
+ * 6. The accent-as-text anti-pattern — `--color-<f>` as bare text on a page
21
+ * or card surface. check-contrast validates `on-<f>` over `<f>` and never
22
+ * looks at accent-over-surface, so the pair passes by being unexamined.
23
+ * This is how `.app-header__title` shipped 1.92:1 in izakaya-light.
24
+ *
25
+ * 5 and 6 run on markup too, as Tailwind classes (`text-success` on a card,
26
+ * `bg-success-bg text-success`) — CSS-only, hadoku_site had 49 of them while
27
+ * this gate reported OK.
28
+ *
29
+ * node themes/scripts/check-usage.mjs [paths...] (default: cwd)
30
+ */
31
+
32
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
33
+ import { join, extname, relative } from 'node:path'
34
+ import { parseThemes, allTokenNames, THEMES, FAMILIES } from './lib/parse-themes.mjs'
35
+
36
+ const roots = process.argv.slice(2).filter(a => !a.startsWith('--'))
37
+ const targets = roots.length ? roots : [process.cwd()]
38
+
39
+ const themes = parseThemes()
40
+ // Only tokens defined in EVERY theme count as usable.
41
+ const known = new Set(allTokenNames(themes).filter(n => THEMES.every(t => themes[t][n])))
42
+
43
+ // `.claude` holds git worktrees — separate checkouts that lint themselves.
44
+ const SKIP_DIRS = new Set([
45
+ 'node_modules',
46
+ 'dist',
47
+ '.git',
48
+ '.claude',
49
+ 'playwright-report',
50
+ 'test-results',
51
+ '.profiler',
52
+ 'calendar-mockups'
53
+ ])
54
+ const STYLE_EXT = new Set(['.css'])
55
+ const MARKUP_EXT = new Set(['.tsx', '.jsx', '.astro', '.html', '.vue', '.svelte'])
56
+
57
+ function* walk(dir) {
58
+ let entries
59
+ try {
60
+ entries = readdirSync(dir)
61
+ } catch {
62
+ return
63
+ }
64
+ for (const entry of entries) {
65
+ if (SKIP_DIRS.has(entry)) continue
66
+ const full = join(dir, entry)
67
+ let st
68
+ try {
69
+ st = statSync(full)
70
+ } catch {
71
+ continue
72
+ }
73
+ if (st.isDirectory()) yield* walk(full)
74
+ else yield full
75
+ }
76
+ }
77
+
78
+ const problems = []
79
+ /**
80
+ * Opt-out, one line at a time:
81
+ *
82
+ * \/* check-usage-disable-next-line *\/
83
+ * color: var(--color-warning);
84
+ *
85
+ * Deliberately per-line and comment-based, so every suppression sits next to the
86
+ * thing it excuses and shows up in review. The real case it exists for is
87
+ * DEMONSTRATING an anti-pattern — dev/gallery renders a bare accent glyph beside
88
+ * the tile treatments precisely so the contrast failure is visible. Without a
89
+ * suppression the only ways to ship that are to weaken the rule for everyone or
90
+ * to hide the colour somewhere the scanner cannot see, and both are worse.
91
+ */
92
+ const DISABLE_NEXT = /(?:\/\*|\/\/|<!--)\s*check-usage-disable-next-line/
93
+
94
+ /** Lines whose finding was explicitly waived by the preceding line. */
95
+ const suppressed = new Map()
96
+
97
+ const add = (file, line, kind, detail) => {
98
+ if (suppressed.get(file)?.has(line)) return
99
+ problems.push({ file, line, kind, detail })
100
+ }
101
+
102
+ /** Tokens a file is allowed to define locally (app-scoped, not theme tokens). */
103
+ const localDefinitions = new Set()
104
+
105
+ for (const target of targets) {
106
+ const base = statSync(target).isDirectory() ? target : null
107
+ const files = base ? [...walk(base)] : [target]
108
+
109
+ // Pass 1: collect locally-defined custom properties so app-level vars
110
+ // (e.g. --skeleton-shimmer) don't read as unknown theme tokens.
111
+ for (const file of files) {
112
+ if (!STYLE_EXT.has(extname(file))) continue
113
+ const text = readFileSync(file, 'utf8')
114
+ for (const m of text.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gim)) localDefinitions.add(m[1])
115
+ }
116
+
117
+ for (const file of files) {
118
+ const ext = extname(file)
119
+ const isStyle = STYLE_EXT.has(ext)
120
+ const isMarkup = MARKUP_EXT.has(ext)
121
+ if (!isStyle && !isMarkup) continue
122
+ // The package's own sources define the tokens; don't lint them against themselves.
123
+ if (file.includes(join('themes', 'src')) || file.includes(join('themes', 'dev'))) continue
124
+
125
+ const rel = relative(process.cwd(), file)
126
+ const lines = readFileSync(file, 'utf8').split(/\r?\n/)
127
+
128
+ // Collect waivers before scanning, so a rule reported by offset (the
129
+ // block-level checks below) is covered as well as the per-line ones.
130
+ const waived = new Set()
131
+ lines.forEach((line, i) => {
132
+ if (DISABLE_NEXT.test(line)) waived.add(i + 2)
133
+ })
134
+ suppressed.set(rel, waived)
135
+
136
+ lines.forEach((line, i) => {
137
+ const n = i + 1
138
+
139
+ // (3) fallbacks — banned outright, they mask every other failure.
140
+ // Covers every package-owned prefix, not just colours: --hdk-* fails the
141
+ // same silent way (a stale --hdk-space-md renders the hardcoded px).
142
+ for (const m of line.matchAll(/var\(\s*(--(?:color|hdk)-[a-z0-9-]+)\s*,/gi)) {
143
+ add(rel, n, 'fallback', `var(${m[1]}, …) — fallbacks hide broken tokens; drop the fallback`)
144
+ }
145
+
146
+ // (1) unknown custom properties in var()
147
+ for (const m of line.matchAll(/var\(\s*(--color-[a-z0-9-]+)\s*[,)]/gi)) {
148
+ const name = m[1]
149
+ if (!known.has(name) && !localDefinitions.has(name)) {
150
+ add(rel, n, 'unknown-token', `var(${name}) is not defined by @wolffm/themes`)
151
+ }
152
+ }
153
+
154
+ // (2) Tailwind colour classes in markup
155
+ if (isMarkup) {
156
+ for (const m of line.matchAll(
157
+ /(?<![\w-])(?:bg|text|border|ring|fill|stroke|from|to|via|outline|decoration|accent|caret|divide|shadow)-((?:on-)?(?:primary|success|warning|danger|neutral|text|border|bg)(?:-[a-z0-9]+)*)(?![\w-])/g
158
+ )) {
159
+ const token = `--color-${m[1]}`
160
+ if (!known.has(token)) {
161
+ add(rel, n, 'unknown-class', `${m[0]} → ${token} is not mapped by @wolffm/themes`)
162
+ }
163
+ }
164
+
165
+ // (4)/(5) in markup: the same two anti-patterns, written as Tailwind
166
+ // classes. `text-success` on a card is `color: var(--color-success)` on
167
+ // a card, and the CSS pass below never sees a .tsx. The scope is one
168
+ // quoted string — the class list — so a fill added conditionally in a
169
+ // separate clsx() argument does not excuse the unfilled case.
170
+ for (const q of line.matchAll(/(["'`])((?:(?!\1).)*)\1/g)) {
171
+ const classes = q[2]
172
+ for (const m of classes.matchAll(
173
+ /(?<![\w-])text-(primary|success|warning|danger|neutral)(-dark)?(?![\w-])/g
174
+ )) {
175
+ const family = m[1]
176
+ const has = re => new RegExp(`(?<![\\w-])${re}(?![\\w-])`).test(classes)
177
+ if (has(`bg-${family}-bg`)) {
178
+ add(
179
+ rel,
180
+ n,
181
+ 'tint-pair',
182
+ `${m[0]} on bg-${family}-bg fails AA in most themes; use text-on-${family}-bg`
183
+ )
184
+ continue
185
+ }
186
+ // A filled backdrop of its own family is the class list choosing
187
+ // its surface, like rule (5)'s paintsOwnBg. A surface class
188
+ // (bg-bg-card) is not: that is exactly the unvalidated pair.
189
+ if (has(`bg-${family}(?:-dark|-hover)?`)) continue
190
+ add(
191
+ rel,
192
+ n,
193
+ 'accent-as-text',
194
+ `${m[0]} is a fill colour, not a text colour; on a page or card surface it ` +
195
+ `fails contrast in most themes. Use text-text, or bg-${family}-bg + text-on-${family}-bg.`
196
+ )
197
+ }
198
+ }
199
+ }
200
+ })
201
+
202
+ if (isStyle) {
203
+ const text = lines.join('\n')
204
+
205
+ // The cascade-layer constraint that makes the whole mapping work.
206
+ if (text.match(/@import\s+["'][^"']*@wolffm\/themes\/style\.css["']\s+layer\(/)) {
207
+ add(
208
+ rel,
209
+ 1,
210
+ 'layered-import',
211
+ 'style.css must be imported UNLAYERED or every colour resolves to nothing'
212
+ )
213
+ }
214
+
215
+ // (4) The tint anti-pattern: `<f>` as text on the `<f>-bg` surface.
216
+ // This is the exact pair that failed AA in 62 of 90 theme/family
217
+ // combinations, and no amount of token checking catches it — both
218
+ // tokens exist, they just must not be used together.
219
+ //
220
+ // (5) The accent-as-text anti-pattern: `<f>` or `<f>-dark` as bare text,
221
+ // with no filled background of its own family in the rule. Whatever
222
+ // surface it lands on is then the page or a card, and THAT pair is
223
+ // validated by nothing: check-contrast only checks `on-<f>` over `<f>`,
224
+ // so every accent-over-surface combination passes by never being looked
225
+ // at. It is not a sanctioned pair — a fill colour is legible only under
226
+ // `--color-on-<f>`. `.app-header__title` shipped `--color-primary` this
227
+ // way and missed the 3:1 large-text floor in 6 of 18 themes, in all nine
228
+ // consuming apps at once. Text on a surface is `--color-text`; to carry
229
+ // an accent, pair `--color-<f>-bg` with `--color-on-<f>-bg`.
230
+ const stripped = text.replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, ' '))
231
+ const lineAt = offset => stripped.slice(0, offset).split('\n').length
232
+ for (const rule of stripped.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
233
+ const body = rule[2]
234
+ const bodyStart = rule.index + rule[1].length + 1
235
+ const bg = body.match(/background(?:-color)?\s*:\s*var\(--color-([a-z]+)-bg\)/)
236
+ const fg = body.match(/(?<!-)color\s*:\s*var\(--color-([a-z]+)\)\s*;/)
237
+ if (bg && fg && bg[1] === fg[1]) {
238
+ add(
239
+ rel,
240
+ lineAt(bodyStart + fg.index),
241
+ 'tint-pair',
242
+ `--color-${fg[1]} on --color-${bg[1]}-bg fails AA in most themes; use --color-on-${fg[1]}-bg`
243
+ )
244
+ continue
245
+ }
246
+
247
+ // Only *bare* accent text is the anti-pattern. A rule that paints its
248
+ // own background has chosen its backdrop explicitly — whether that is
249
+ // `var(--color-primary)` (a filled button: check-contrast's `on-<f>`
250
+ // over `<f>` pair) or a hardcoded gradient (the theme-picker's fixed
251
+ // light/dark pill previews). Neither lands on the page surface, which
252
+ // is the pair this check exists to catch. `none`/`transparent` do not
253
+ // count: they are exactly how a button declares it has no backdrop and
254
+ // inherits whatever is behind it.
255
+ const declaredBg = body.match(/background(?:-color)?\s*:\s*([^;]+)/)
256
+ const paintsOwnBg =
257
+ declaredBg && !/^\s*(none|transparent|inherit|initial|unset)\s*$/i.test(declaredBg[1])
258
+ if (paintsOwnBg) continue
259
+ for (const m of body.matchAll(/(?<!-)color\s*:\s*var\(--color-([a-z]+)(-dark)?\)\s*;/g)) {
260
+ const family = m[1]
261
+ if (!FAMILIES.includes(family)) continue
262
+ add(
263
+ rel,
264
+ lineAt(bodyStart + m.index),
265
+ 'accent-as-text',
266
+ `--color-${family}${m[2] ?? ''} is a fill colour, not a text colour; ` +
267
+ `on a page or card surface it fails contrast in most themes. ` +
268
+ `Use --color-text, or --color-${family}-bg + --color-on-${family}-bg.`
269
+ )
270
+ }
271
+ }
272
+ }
273
+ }
274
+ }
275
+
276
+ const BY_KIND = {
277
+ 'unknown-token': 'Unknown theme tokens',
278
+ 'unknown-class': 'Tailwind classes with no matching token',
279
+ fallback: 'var() fallbacks (banned — they hide broken tokens)',
280
+ 'layered-import': 'style.css imported into a cascade layer',
281
+ 'tint-pair': 'Family colour used as text on its own tint (fails AA in most themes)',
282
+ 'accent-as-text': 'Fill colour used as bare text on a page/card surface (unvalidated pair)'
283
+ }
284
+
285
+ if (!problems.length) {
286
+ console.log(`check-usage: OK — every token reference resolves (${known.size} tokens known).`)
287
+ process.exit(0)
288
+ }
289
+
290
+ for (const [kind, label] of Object.entries(BY_KIND)) {
291
+ const rows = problems.filter(p => p.kind === kind)
292
+ if (!rows.length) continue
293
+ console.error(`\n${label} (${rows.length}):`)
294
+ for (const p of rows) console.error(` ${p.file}:${p.line} ${p.detail}`)
295
+ }
296
+ console.error(`\ncheck-usage: FAILED — ${problems.length} problem(s).`)
297
+ process.exit(1)
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Apply the mechanical half of the emoji -> icon migration.
4
+ *
5
+ * node codemod-icons.mjs <path> [--write] [--quiet]
6
+ *
7
+ * Dry-run by default. `--write` edits in place.
8
+ *
9
+ * WHAT IT WILL TOUCH, and nothing else:
10
+ *
11
+ * 1. `"icon": "✅"` in JSON, and `icon: '✅'` in object literals — a pure string
12
+ * swap with no surrounding syntax to get wrong.
13
+ * 2. A JSX text node that is ONLY an emoji -> <Icon name="check" />
14
+ * 3. An emoji LEADING a JSX text run -> <Icon name="check" /> the rest
15
+ *
16
+ * WHAT IT DELIBERATELY LEAVES ALONE:
17
+ *
18
+ * - `'🗑️ Delete Board'` — a string, not JSX. Turning it into an element changes
19
+ * the call site's type, and whether that call site even accepts a ReactNode is
20
+ * a question this script cannot answer. (In hadoku-task the equivalent change
21
+ * required widening ContextMenu's `label` from string to ReactNode first.)
22
+ * - String literals that are only an emoji, for the same reason: whether the
23
+ * value is rendered, logged, or compared is not knowable from the literal.
24
+ * - Any emoji with no entry in emoji-map.json.
25
+ * - Any emoji listed under `$ambiguous` there — `◀`/`▶`, whose correct icon depends
26
+ * on whether it is a transport control or one arm of a mirrored nav pair. Rewriting
27
+ * those off the map alone writes a mismatched pair automatically, everywhere.
28
+ * - Anything within a few lines of an emoji it is leaving raw, for any of the reasons
29
+ * above. Refusing `▶ Play` while rewriting the `⏸ Pause` beside it still ships an
30
+ * SVG next to a glyph — the refusal only helps if its neighbours wait with it.
31
+ *
32
+ * Those are reported, not guessed at. A codemod that is wrong 5% of the time
33
+ * across twelve repos costs more review than it saves.
34
+ */
35
+ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs'
36
+ import { join, extname, relative, resolve, dirname, sep } from 'node:path'
37
+ import { fileURLToPath } from 'node:url'
38
+ import { loadIconRegistry, MISSING_REGISTRY_MESSAGE } from './lib/icon-registry.mjs'
39
+
40
+ const here = dirname(fileURLToPath(import.meta.url))
41
+ const args = process.argv.slice(2)
42
+ const roots = args.filter(a => !a.startsWith('--'))
43
+ const write = args.includes('--write')
44
+ const quiet = args.includes('--quiet')
45
+ if (!roots.length) {
46
+ console.error('usage: codemod-icons.mjs <path> [--write]')
47
+ process.exit(1)
48
+ }
49
+
50
+ // Same two-tree resolution as check-icons — an unguarded read here threw a raw
51
+ // ENOENT stack at anyone running the codemod from a consumer install.
52
+ const { emojiMap: MAP, ambiguous: AMBIGUOUS_RAW } = await loadIconRegistry()
53
+ if (!MAP) {
54
+ console.error(`codemod-icons: ${MISSING_REGISTRY_MESSAGE}`)
55
+ process.exit(1)
56
+ }
57
+ const stripVs = s =>
58
+ [...s]
59
+ .filter(c => {
60
+ const p = c.codePointAt(0)
61
+ return p !== 0xfe0f && p !== 0xfe0e && !(p >= 0x1f3fb && p <= 0x1f3ff)
62
+ })
63
+ .join('')
64
+ const EMOJI_TO_ICON = new Map()
65
+ for (const [e, n] of Object.entries(MAP)) {
66
+ if (!e.startsWith('$') && typeof n === 'string') EMOJI_TO_ICON.set(stripVs(e), n)
67
+ }
68
+ /**
69
+ * Glyphs listed under `$ambiguous` in emoji-map.json resolve two ways depending on
70
+ * what sits beside them, so they are exactly the "needs judgement" case this script
71
+ * refuses. Rewriting `◀ Prev` / `Next ▶` off the map alone emits chevron-left beside
72
+ * play — a mismatched pair, written automatically, in every repo the codemod touches.
73
+ */
74
+ const AMBIGUOUS = new Map(
75
+ Object.entries(AMBIGUOUS_RAW ?? {}).map(([e, note]) => [stripVs(e), note])
76
+ )
77
+ const nameFor = e => (AMBIGUOUS.has(stripVs(e)) ? undefined : EMOJI_TO_ICON.get(stripVs(e)))
78
+ const ambiguityNote = e => AMBIGUOUS.get(stripVs(e))
79
+
80
+ const SKIP_DIRS = new Set([
81
+ 'node_modules', '.git', 'dist', 'build', 'out', '.next', '.astro', '.claude',
82
+ 'playwright-report', 'test-results', '.profiler', '.wrangler', 'coverage',
83
+ 'vendor', 'third_party', '__pycache__', '.venv'
84
+ ])
85
+ const GENERATED_PATH =
86
+ /(^|\/)(public\/(mf|v\d+)|\.output|\.vercel|storybook-static|bundle)(\/|$)|\.(min|bundle|generated)\.[a-z]+$/
87
+ const NON_UI_CODE_DIR =
88
+ /(^|\/)(workers?|services?|server|scripts?|functions|cli|bin|migrations|e2e|tests?|__tests__)(\/|$)/
89
+ const TEST_FILE = /\.(spec|test)\.[cm]?[jt]sx?$/
90
+ const MARKUP_EXT = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html'])
91
+ const SCRIPT_EXT = new Set(['.ts', '.js', '.mjs', '.cjs'])
92
+
93
+ const EMOJI_RE =
94
+ /(?:\p{RI}\p{RI}|[#*0-9]️?⃣|\p{Extended_Pictographic}(?:️|[\u{1F3FB}-\u{1F3FF}])*(?:‍\p{Extended_Pictographic}(?:️|[\u{1F3FB}-\u{1F3FF}])*)*)/gu
95
+ const isEmoji = s => /\p{Extended_Pictographic}|\p{RI}\p{RI}|⃣/u.test(s)
96
+
97
+ function* walk(dir) {
98
+ let entries
99
+ try {
100
+ entries = readdirSync(dir, { withFileTypes: true })
101
+ } catch {
102
+ return
103
+ }
104
+ for (const e of entries) {
105
+ if (SKIP_DIRS.has(e.name)) continue
106
+ const full = join(dir, e.name)
107
+ if (e.isSymbolicLink()) continue
108
+ if (e.isDirectory()) yield* walk(full)
109
+ else yield full
110
+ }
111
+ }
112
+
113
+ const changed = []
114
+ const skipped = []
115
+ let filesTouched = 0
116
+
117
+ for (const root of roots) {
118
+ const abs = resolve(root)
119
+ if (!existsSync(abs)) {
120
+ console.error(`codemod-icons: no such path: ${root}`)
121
+ process.exit(1)
122
+ }
123
+ for (const file of walk(abs)) {
124
+ const ext = extname(file)
125
+ const isMarkup = MARKUP_EXT.has(ext)
126
+ const isJson = ext === '.json'
127
+ const isScript = SCRIPT_EXT.has(ext)
128
+ if (!isMarkup && !isJson && !isScript) continue
129
+ const posix = file.split(sep).join('/')
130
+ if (GENERATED_PATH.test(posix) || TEST_FILE.test(posix)) continue
131
+ if (!isMarkup && NON_UI_CODE_DIR.test(posix)) continue
132
+
133
+ let st
134
+ try {
135
+ st = statSync(file)
136
+ } catch {
137
+ continue
138
+ }
139
+ if (st.size > 2 * 1024 * 1024) continue
140
+
141
+ const original = readFileSync(file, 'utf8')
142
+ if (!EMOJI_RE.test(original)) continue
143
+ EMOJI_RE.lastIndex = 0
144
+
145
+ const rel = relative(process.cwd(), file)
146
+ const lines = original.split('\n')
147
+ let jsxEdits = 0
148
+ let fileEdits = 0
149
+
150
+ const isNonUi = l =>
151
+ /^(\/\/|\/\*|\*|#|<!--)/.test(l.trim()) ||
152
+ /\b(console|logger|log)\s*\.\s*(log|warn|error|info|debug|table|trace|group)\b/.test(l) ||
153
+ /process\.stdout|\bprint\(/.test(l)
154
+
155
+ /**
156
+ * Where a `//` line comment starts, or the line length. Everything past it
157
+ * renders nothing, so no rule may rewrite there AND no emoji there can clash
158
+ * with an icon beside it. `isNonUi` only catches lines that BEGIN with a comment
159
+ * marker, and pygmalion's icons.tsx documents its whole registry in TRAILING
160
+ * ones — `export const WarnIcon = TriangleAlert // ⚠` would otherwise become
161
+ * `// <Icon name="warning" />`, rewriting prose about an icon into an icon.
162
+ * Quote-aware, so a `//` inside a string is not mistaken for a comment.
163
+ */
164
+ const codeEndOf = line => {
165
+ let quote = null
166
+ for (let k = 0; k < line.length; k++) {
167
+ const c = line[k]
168
+ if (quote) {
169
+ if (c === '\\') k++
170
+ else if (c === quote) quote = null
171
+ continue
172
+ }
173
+ if (c === "'" || c === '"' || c === '`') { quote = c; continue }
174
+ if (c === '/' && line[k + 1] === '/') return k
175
+ }
176
+ return line.length
177
+ }
178
+
179
+ /**
180
+ * One line's rewrite, isolated so it can be run twice: once as a PROBE that
181
+ * answers "does this line still hold a raw emoji afterwards", and once for
182
+ * real. `report` is null on the probe pass so nothing is logged twice.
183
+ */
184
+ const convertLine = (line, i, report) => {
185
+ let jsx = 0
186
+ let edits = 0
187
+ const say = msg => { if (report) report.push(msg) }
188
+
189
+ // (1) icon/glyph/emoji field values — pure string swap.
190
+ line = line.replace(
191
+ /((?:"(?:icon|glyph|emoji)"|(?<![\w$])(?:icon|glyph|emoji))\s*[:=]\s*)(['"])([^'"]*)\2/g,
192
+ (m, head, q, val) => {
193
+ const hits = [...val.matchAll(EMOJI_RE)].map(x => x[0]).filter(isEmoji)
194
+ if (!hits.length || hits.join('') !== val.trim()) return m
195
+ const name = nameFor(hits[0])
196
+ if (!name) {
197
+ const note = ambiguityNote(hits[0])
198
+ say(
199
+ note
200
+ ? `${rel}:${i + 1} ${hits[0]} needs judgement — ${note}`
201
+ : `${rel}:${i + 1} no mapping for ${val}`
202
+ )
203
+ return m
204
+ }
205
+ edits++
206
+ return `${head}${q}${name}${q}`
207
+ }
208
+ )
209
+
210
+ // Only JSX files get an <Icon> element. An .astro/.vue/.svelte/.html file
211
+ // has no such component in scope and the import block below cannot give it
212
+ // one — emitting <Icon> there produces markup that does not compile. Astro
213
+ // wants `set:html={getIconSvg('name')}` with a frontmatter import, which is
214
+ // a different edit in a different part of the file, so it is reported
215
+ // instead of guessed at.
216
+ const canEmitJsx = ext === '.tsx' || ext === '.jsx'
217
+ const codeEnd = codeEndOf(line)
218
+
219
+ /**
220
+ * The icon name for a token that is NOTHING BUT emoji.
221
+ * undefined — not an emoji-only token, ignore it silently
222
+ * null — emoji, but this run will not convert it (reason logged)
223
+ */
224
+ const resolveToken = tok => {
225
+ const hits = [...tok.matchAll(EMOJI_RE)].map(x => x[0]).filter(isEmoji)
226
+ if (!hits.length || hits.join('') !== tok) return undefined
227
+ const name = nameFor(tok)
228
+ if (name) return name
229
+ const note = ambiguityNote(tok)
230
+ say(
231
+ note
232
+ ? `${rel}:${i + 1} ${tok} needs judgement — ${note}`
233
+ : `${rel}:${i + 1} no mapping for ${tok}`
234
+ )
235
+ return null
236
+ }
237
+
238
+ // An emoji OPENING a text run — `<div>👤 {value}</div>`, the commonest shape.
239
+ const LEADING = /(^|>|\{' '\}|\{" "\})(\s*)([^\s<>{}'"`]+)(\s*)(?=$|<|[A-Za-z({])/g
240
+ // An emoji CLOSING one — `<b>{disp(m.b)}</b> wins ▶`. LEADING anchors on `>` or
241
+ // line start, so a glyph sitting AFTER its label was invisible to it: the codemod
242
+ // stayed silent on pygmalion's BakeoffReview `wins ▶` and on promptsmith's
243
+ // `Pick B ▶` while check-icons reported both. The two tools now agree.
244
+ //
245
+ // Anchored on the whitespace before the token and a `<` or end-of-line after it,
246
+ // which is what keeps it out of strings and attributes: the token class excludes
247
+ // quotes and braces, so `'⚠ ' + err` and `className="a b"` cannot produce one.
248
+ const TRAILING = /(\s)([^\s<>{}'"`]+)(\s*)(?=<|$)/g
249
+
250
+ if (isMarkup && !canEmitJsx) {
251
+ // Report only — an .astro/.vue/.html file has no <Icon> in scope.
252
+ const seen = new Set()
253
+ for (const re of [LEADING, TRAILING]) {
254
+ re.lastIndex = 0
255
+ for (const m of line.matchAll(re)) {
256
+ const tok = re === LEADING ? m[3] : m[2]
257
+ if (m.index + m[0].indexOf(tok) >= codeEnd) continue
258
+ if (seen.has(tok)) continue
259
+ const name = resolveToken(tok)
260
+ if (name === undefined || name === null) continue
261
+ seen.add(tok)
262
+ say(
263
+ `${rel}:${i + 1} ${tok} -> ${name} (${ext} needs getIconSvg + a frontmatter import, by hand)`
264
+ )
265
+ }
266
+ }
267
+ }
268
+ if (canEmitJsx) {
269
+ line = line.replace(LEADING, (m, pre, ws, tok, post, offset) => {
270
+ if (offset + pre.length + ws.length >= codeEnd) return m
271
+ const name = resolveToken(tok)
272
+ if (!name) return m
273
+ jsx++
274
+ edits++
275
+ return `${pre}${ws}<Icon name="${name}" />${post}`
276
+ })
277
+ line = line.replace(TRAILING, (m, pre, tok, post, offset) => {
278
+ if (offset + pre.length >= codeEnd) return m
279
+ const name = resolveToken(tok)
280
+ if (!name) return m
281
+ jsx++
282
+ edits++
283
+ return `${pre}<Icon name="${name}" />${post}`
284
+ })
285
+ }
286
+ return { line, jsx, edits }
287
+ }
288
+
289
+ /**
290
+ * PARTIAL PAIRS. Converting one glyph of a control group while its neighbour
291
+ * stays a raw emoji is the failure this guard exists for: `▶ Play` is refused
292
+ * as ambiguous, `⏸ Pause` on the next line is not, and --write leaves an SVG
293
+ * sitting beside an emoji — the very mismatch the refusal was meant to avoid.
294
+ *
295
+ * A line that STILL holds an emoji after its own rewrite is one this run is
296
+ * leaving raw, whatever the reason (ambiguous, unmapped, a bare string, an
297
+ * .html file with no <Icon> in scope). Nothing within PAIR_WINDOW lines of one
298
+ * is converted; it is reported instead, so the human fixes the group together.
299
+ *
300
+ * Comments are excluded — an emoji in a comment renders nothing, so it cannot
301
+ * clash with an icon beside it.
302
+ *
303
+ * The window is deliberately small. Sibling controls in one row sit within a
304
+ * few lines of each other (the promptsmith tournament row spanned five), while
305
+ * unrelated emoji are typically far off — pygmalion's BakeoffReview has a `▶`
306
+ * at line 310 and its next emoji at 31 and 471. File scope would have blocked
307
+ * both of those for no reason.
308
+ */
309
+ const PAIR_WINDOW = 4
310
+ const retainsRawEmoji = lines.map((l, i) => {
311
+ if (isNonUi(l)) return false
312
+ const { line: after } = convertLine(l, i, null)
313
+ const code = after.slice(0, codeEndOf(after))
314
+ return [...code.matchAll(EMOJI_RE)].some(m => isEmoji(m[0]))
315
+ })
316
+ const nearRetained = i => {
317
+ const lo = Math.max(0, i - PAIR_WINDOW)
318
+ const hi = Math.min(lines.length - 1, i + PAIR_WINDOW)
319
+ for (let j = lo; j <= hi; j++) if (retainsRawEmoji[j]) return true
320
+ return false
321
+ }
322
+
323
+ for (let i = 0; i < lines.length; i++) {
324
+ if (isNonUi(lines[i])) continue
325
+ // Run for real either way: a deferred line still owes its own reasons
326
+ // ("▶ needs judgement"), which are what tell the human what to do.
327
+ const r = convertLine(lines[i], i, skipped)
328
+ if (nearRetained(i)) {
329
+ if (r.edits) {
330
+ skipped.push(
331
+ `${rel}:${i + 1} deferred — a raw emoji this run cannot convert sits within ` +
332
+ `${PAIR_WINDOW} lines; converting this one alone would leave an SVG beside a glyph`
333
+ )
334
+ }
335
+ continue
336
+ }
337
+ if (r.line !== lines[i]) {
338
+ lines[i] = r.line
339
+ changed.push(`${rel}:${i + 1}`)
340
+ }
341
+ jsxEdits += r.jsx
342
+ fileEdits += r.edits
343
+ }
344
+
345
+ if (!fileEdits) continue
346
+ let out = lines.join('\n')
347
+
348
+ // Add the import only when JSX was actually produced.
349
+ if (jsxEdits && (ext === '.tsx' || ext === '.jsx') && !/from '@wolffm\/themes'/.test(out)) {
350
+ // Insert after the line that CLOSES the last import, not after the last
351
+ // line that merely starts with `import`. A multi-line
352
+ // import {
353
+ // a, b
354
+ // } from 'x'
355
+ // matches the naive test on its first line, and inserting there lands the
356
+ // new import INSIDE the braces — which is exactly what it did to
357
+ // pygmalion's BakeoffReview.tsx, and the build caught it.
358
+ const src = out.split('\n')
359
+ let lastImport = -1
360
+ let inImport = false
361
+ for (let idx = 0; idx < src.length; idx++) {
362
+ const l = src[idx]
363
+ if (!inImport && /^\s*import\b/.test(l)) {
364
+ // Single-line import, or the head of a multi-line one.
365
+ if (/\bfrom\s*['"][^'"]+['"]\s*;?\s*$/.test(l) || /^\s*import\s*['"]/.test(l)) lastImport = idx
366
+ else inImport = true
367
+ } else if (inImport && /\bfrom\s*['"][^'"]+['"]\s*;?\s*$/.test(l)) {
368
+ lastImport = idx
369
+ inImport = false
370
+ }
371
+ }
372
+ if (lastImport >= 0) {
373
+ src.splice(lastImport + 1, 0, "import { Icon } from '@wolffm/themes'")
374
+ out = src.join('\n')
375
+ } else {
376
+ skipped.push(`${rel} produced JSX but has no import block — add Icon import by hand`)
377
+ }
378
+ }
379
+
380
+ filesTouched++
381
+ if (write) writeFileSync(file, out)
382
+ }
383
+ }
384
+
385
+ if (!quiet) {
386
+ for (const c of changed) console.log(` edit ${c}`)
387
+ if (skipped.length) {
388
+ console.log('\n left for a human:')
389
+ for (const s of [...new Set(skipped)]) console.log(` ${s}`)
390
+ }
391
+ }
392
+ console.log(
393
+ `\ncodemod-icons: ${changed.length} replacement(s) in ${filesTouched} file(s)` +
394
+ `${skipped.length ? `, ${new Set(skipped).size} left for a human` : ''}` +
395
+ `${write ? '' : ' [dry run — pass --write to apply]'}`
396
+ )