@hadoku/themes 0.0.0-stage → 5.12.16
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.
- package/LICENSE +21 -0
- package/LICENSE-lucide +43 -0
- package/README.md +433 -2
- package/THEME_USAGE_GUIDE.md +308 -0
- package/dist/HadokuThemeRoot.d.ts +53 -0
- package/dist/HadokuThemeRoot.js +66 -0
- package/dist/ThemeIcons.d.ts +31 -0
- package/dist/ThemeIcons.js +91 -0
- package/dist/effects.d.ts +25 -0
- package/dist/effects.js +25 -0
- package/dist/icons/Icon.d.ts +36 -0
- package/dist/icons/Icon.js +89 -0
- package/dist/icons/index.d.ts +61 -0
- package/dist/icons/index.js +87 -0
- package/dist/icons/registry.generated.d.ts +158 -0
- package/dist/icons/registry.generated.js +446 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +169 -0
- package/dist/metadata.d.ts +35 -0
- package/dist/metadata.js +108 -0
- package/dist/platform/index.d.ts +94 -0
- package/dist/platform/index.js +149 -0
- package/dist/platform/usePlatform.d.ts +29 -0
- package/dist/platform/usePlatform.js +65 -0
- package/dist/prefsApiBase.d.ts +37 -0
- package/dist/prefsApiBase.js +40 -0
- package/dist/theme-types.d.ts +34 -0
- package/dist/theme-types.js +1 -0
- package/dist/themeContext.d.ts +62 -0
- package/dist/themeContext.js +70 -0
- package/dist/themePrefs.d.ts +29 -0
- package/dist/themePrefs.js +48 -0
- package/dist/useRefElement.d.ts +19 -0
- package/dist/useRefElement.js +52 -0
- package/dist/useTheme.d.ts +46 -0
- package/dist/useTheme.js +216 -0
- package/package.json +83 -4
- package/scripts/audit-tokens.mjs +126 -0
- package/scripts/check-contrast.mjs +24 -0
- package/scripts/check-docs.mjs +53 -0
- package/scripts/check-icons.mjs +473 -0
- package/scripts/check-usage.mjs +297 -0
- package/scripts/codemod-icons.mjs +396 -0
- package/scripts/generate-icons.mjs +199 -0
- package/scripts/generate-preview.mjs +374 -0
- package/scripts/generate-tailwind-colors.mjs +109 -0
- package/scripts/lib/color.mjs +184 -0
- package/scripts/lib/contract.d.mts +58 -0
- package/scripts/lib/contract.mjs +348 -0
- package/scripts/lib/derive.mjs +324 -0
- package/scripts/lib/icon-registry.mjs +92 -0
- package/scripts/lib/parse-themes.mjs +19 -0
- package/scripts/lib/tokens.mjs +188 -0
- package/scripts/normalize-tokens.mjs +161 -0
- package/src/advanced.css +218 -0
- package/src/icons/Icon.tsx +151 -0
- package/src/icons/emoji-map.json +202 -0
- package/src/icons/index.ts +151 -0
- package/src/icons/registry.generated.ts +455 -0
- package/src/icons/sources.json +150 -0
- package/src/icons.css +145 -0
- package/src/style.css +1421 -0
- package/src/tailwind-colors.css +88 -0
- 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
|
+
)
|