@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.
- 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,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`)
|