@hanzo/gui 8.0.0 → 8.0.2

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/css-check.mjs ADDED
@@ -0,0 +1,624 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gui-css-check — every class in the markup must have a rule in the CSS that
4
+ * ships with it.
5
+ *
6
+ * A build is green when the code compiles. Nothing in a compiler knows whether
7
+ * the stylesheet a document links actually contains the classes that document
8
+ * uses, so "classes without rules" ships silently and renders a blank-looking
9
+ * page. It has happened three times here: hanzo.app served 18KB of CSS holding
10
+ * zero of its ~240 gui atomic classes; @hanzogui/shell@8.0.3 published 966
11
+ * lines of Tailwind classes with no Tailwind anywhere in its dependencies.
12
+ * Both type-checked. Both built. Both were completely unstyled in production.
13
+ *
14
+ * This reads the built output and compares two sets:
15
+ * used — class tokens in class="..." attributes of the rendered markup
16
+ * defined — class tokens in selector position across every sheet the page
17
+ * delivers: linked .css files AND inline <style>
18
+ * Anything used and not defined is a miss and the process exits 1.
19
+ *
20
+ * Per page, not per build: a rule that exists in some sheet the page never
21
+ * links is not delivered, and the browser agrees.
22
+ *
23
+ * Zero dependencies, one file. It has to run in any app without dragging a
24
+ * toolchain behind it, and it has to keep working when the toolchain is the
25
+ * thing that broke.
26
+ */
27
+ import {
28
+ readFileSync,
29
+ readdirSync,
30
+ statSync,
31
+ existsSync,
32
+ mkdirSync,
33
+ mkdtempSync,
34
+ writeFileSync,
35
+ } from 'node:fs'
36
+ import { join, resolve, relative, basename, sep } from 'node:path'
37
+ import { tmpdir } from 'node:os'
38
+
39
+ // ---------------------------------------------------------------- extraction
40
+
41
+ const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, ' ')
42
+
43
+ /** Strings and url() can hold braces and dots; neither is ever a selector. */
44
+ const stripLiterals = (css) =>
45
+ css
46
+ .replace(/"(?:[^"\\]|\\[\s\S])*"/g, '""')
47
+ .replace(/'(?:[^'\\]|\\[\s\S])*'/g, "''")
48
+ .replace(/url\([^)]*\)/g, 'url()')
49
+
50
+ /** `\3a ` and `\:` both mean a literal `:` in a class name. */
51
+ const unescapeIdent = (s) =>
52
+ s.replace(/\\([0-9a-fA-F]{1,6})[ \t]?|\\([\s\S])/g, (_, hex, ch) =>
53
+ hex ? String.fromCodePoint(parseInt(hex, 16)) : ch
54
+ )
55
+
56
+ /**
57
+ * A class token. The hex-escape branch comes first and swallows its own
58
+ * terminating space (`.a\3a b` is the single class `a:b`) — without that the
59
+ * token would stop at the space and lose everything after it.
60
+ */
61
+ const CLASS_IN_SELECTOR =
62
+ /\.((?:\\[0-9a-fA-F]{1,6}[ \t]?|\\[\s\S]|[^\s.,:;>+~()[\]{}#*%"'\\!])+)/g
63
+
64
+ /**
65
+ * Class tokens in selector position. Walks the sheet tracking brace depth and
66
+ * collects only the text that PRECEDES a `{` — a declaration body never does,
67
+ * so `padding:.5rem` can't masquerade as a class, while rules nested inside
68
+ * `@media`/`@supports`/`@layer` still get read.
69
+ */
70
+ export function definedClasses(css) {
71
+ const src = stripLiterals(stripComments(css))
72
+ const out = new Set()
73
+ let prelude = ''
74
+ let depth = 0
75
+ for (let i = 0; i < src.length; i++) {
76
+ const c = src[i]
77
+ if (c === '{') {
78
+ const sel = prelude.trim()
79
+ // at-rule preludes hold media queries and numbers, never class selectors
80
+ if (sel && !sel.startsWith('@')) {
81
+ for (const m of sel.matchAll(CLASS_IN_SELECTOR)) out.add(unescapeIdent(m[1]))
82
+ }
83
+ prelude = ''
84
+ depth++
85
+ } else if (c === '}') {
86
+ prelude = ''
87
+ depth = Math.max(0, depth - 1)
88
+ } else if (c === ';' && depth > 0) {
89
+ prelude = ''
90
+ } else {
91
+ prelude += c
92
+ }
93
+ }
94
+ return out
95
+ }
96
+
97
+ /** Scripts carry the RSC flight payload, which quotes markup; never markup. */
98
+ const stripNonMarkup = (html) =>
99
+ html
100
+ .replace(/<!--[\s\S]*?-->/g, ' ')
101
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
102
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
103
+
104
+ const ENTITIES = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'" }
105
+
106
+ export function usedClasses(html) {
107
+ const out = new Set()
108
+ const markup = stripNonMarkup(html)
109
+ for (const m of markup.matchAll(/\bclass(?:Name)?\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/gi)) {
110
+ const raw = (m[2] ?? m[3] ?? m[4] ?? '').replace(/&[a-z#0-9]+;/gi, (e) => ENTITIES[e] ?? e)
111
+ for (const cls of raw.split(/\s+/)) if (cls) out.add(cls)
112
+ }
113
+ return out
114
+ }
115
+
116
+ export function inlineStyles(html) {
117
+ return [...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1])
118
+ }
119
+
120
+ export function stylesheetHrefs(html) {
121
+ const out = []
122
+ for (const m of html.matchAll(/<link\b[^>]*>/gi)) {
123
+ const tag = m[0]
124
+ if (!/\brel\s*=\s*["']?[^"'>]*\bstylesheet\b/i.test(tag)) continue
125
+ const href = tag.match(/\bhref\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/i)
126
+ if (href) out.push((href[2] ?? href[3] ?? href[4]).replace(/&amp;/g, '&').split('?')[0])
127
+ }
128
+ return out
129
+ }
130
+
131
+ // ------------------------------------------------------------------ scanning
132
+
133
+ const SKIP_DIR = new Set(['node_modules', '.git', 'cache', 'sourcemaps'])
134
+
135
+ /** Pages the framework ships, not pages this app wrote. */
136
+ const BOILERPLATE = /^(404|500|_error|_not-found|not-found)\.html?$/i
137
+
138
+ function walk(dir, hit, depth = 0) {
139
+ if (depth > 12) return
140
+ let entries
141
+ try {
142
+ entries = readdirSync(dir, { withFileTypes: true })
143
+ } catch {
144
+ return
145
+ }
146
+ for (const e of entries) {
147
+ if (e.name.startsWith('.') && depth > 0) continue
148
+ const p = join(dir, e.name)
149
+ if (e.isDirectory()) {
150
+ if (!SKIP_DIR.has(e.name)) walk(p, hit, depth + 1)
151
+ } else if (e.isFile()) {
152
+ hit(p)
153
+ }
154
+ }
155
+ }
156
+
157
+ /** Built output as a browser sees it: pages, and every sheet on disk. */
158
+ export function collect(roots) {
159
+ const pages = []
160
+ const sheets = new Map() // absolute path -> css text
161
+ for (const root of roots) {
162
+ const st = statSync(root)
163
+ const add = (p) => {
164
+ if (/\.html?$/i.test(p)) pages.push(p)
165
+ else if (/\.css$/i.test(p) && !/\.map$/.test(p)) sheets.set(resolve(p), readFileSync(p, 'utf8'))
166
+ }
167
+ if (st.isDirectory()) walk(root, add)
168
+ else add(root)
169
+ }
170
+ return { pages, sheets }
171
+ }
172
+
173
+ /**
174
+ * `/_next/static/css/x.css` has to become a path on disk. Basename is the one
175
+ * key both sides agree on across Next, Vite and a plain static dir, and every
176
+ * bundler content-hashes the name, so it is unique in practice.
177
+ */
178
+ function indexByBase(sheets) {
179
+ const byBase = new Map()
180
+ for (const [p, css] of sheets) {
181
+ const b = basename(p)
182
+ if (!byBase.has(b)) byBase.set(b, { path: p, css })
183
+ }
184
+ return byBase
185
+ }
186
+
187
+ // -------------------------------------------------------------------- render
188
+
189
+ /**
190
+ * A client-rendered app ships an empty shell, so reading its HTML off disk
191
+ * measures nothing. Rendering it is the only honest way to ask the question,
192
+ * and the browser is also the only thing that knows the full sheet list once
193
+ * a runtime has injected into it.
194
+ *
195
+ * Playwright is imported lazily and is not a dependency of this package: an
196
+ * SSR app never needs it, and the apps that do need it already have it.
197
+ */
198
+ export async function render(urls, { dir }) {
199
+ // @playwright/test is what most repos here install and it re-exports the same
200
+ // chromium; asking only for `playwright` would fail in almost all of them.
201
+ let chromium
202
+ for (const pkg of ['playwright', '@playwright/test']) {
203
+ try {
204
+ ;({ chromium } = await import(pkg))
205
+ break
206
+ } catch {}
207
+ }
208
+ if (!chromium) {
209
+ throw new Error(
210
+ `--render needs playwright, and neither "playwright" nor "@playwright/test"\n` +
211
+ ` resolves from ${process.cwd()}.\n` +
212
+ ` npm i -D @playwright/test && npx playwright install chromium\n` +
213
+ ` Or point gui-css-check at pre-rendered HTML instead.`
214
+ )
215
+ }
216
+ mkdirSync(dir, { recursive: true })
217
+ const browser = await chromium.launch()
218
+ const page = await browser.newPage()
219
+ const out = []
220
+ try {
221
+ for (const [i, url] of urls.entries()) {
222
+ const res = await page.goto(url, { waitUntil: 'networkidle', timeout: 60_000 })
223
+ if (!res || !res.ok()) throw new Error(`${url} returned ${res ? res.status() : 'nothing'}`)
224
+ // Every rule the document actually has, including whatever a runtime
225
+ // injected after load. Linked sheets are kept as separate files under
226
+ // their own basenames and inline ones stay inline, so the shape on disk
227
+ // matches an SSR build exactly and one code path reads both — including
228
+ // the byte split between what a browser caches and what it re-fetches.
229
+ const { linked, inline, html } = await page.evaluate(() => {
230
+ const linked = {}
231
+ const inline = []
232
+ for (const sheet of document.styleSheets) {
233
+ let text
234
+ try {
235
+ text = [...sheet.cssRules].map((r) => r.cssText).join('\n')
236
+ } catch {
237
+ continue // cross-origin sheet, unreadable by design
238
+ }
239
+ const href = sheet.href
240
+ if (href) linked[new URL(href).pathname.split('/').pop()] = text
241
+ else inline.push(text)
242
+ }
243
+ return { linked, inline, html: document.documentElement.outerHTML }
244
+ })
245
+ const stem = `${String(i).padStart(3, '0')}-${url.replace(/[^\w.-]+/g, '_').slice(-60)}`
246
+ for (const [name, text] of Object.entries(linked)) writeFileSync(join(dir, name), text)
247
+ writeFileSync(
248
+ join(dir, `${stem}.html`),
249
+ inline.map((t) => `<style>${t}</style>`).join('') + html
250
+ )
251
+ out.push(url)
252
+ }
253
+ } finally {
254
+ await browser.close()
255
+ }
256
+ // the directory, not the files: the pages' <link> tags still point at the
257
+ // sheets, which now sit beside them under the same basenames
258
+ return [dir]
259
+ }
260
+
261
+ // -------------------------------------------------------------------- config
262
+
263
+ /**
264
+ * Classes a library provably stamps as an IDENTITY MARKER and never styles.
265
+ * Each line cites where it is emitted — an allowance without a source is a
266
+ * guess, and a guess here is how a real miss gets waved through.
267
+ */
268
+ const DEFAULT_ALLOW = [
269
+ // gui, getSplitStyles.tsx: `is_${componentNameFinal}` on every styled
270
+ // component so devtools and tests can name it. Carries no rule by design.
271
+ 'is_*',
272
+ // gui, Theme.tsx: marks a nested (non-root) Theme. The styling comes from
273
+ // the t_* theme class beside it, which does have rules.
274
+ 't_sub_theme',
275
+ // lucide-react, Icon.js: `mergeClasses("lucide", className)` and
276
+ // createLucideIcon.js: `lucide-${name}` — hooks for consumers to target.
277
+ 'lucide',
278
+ 'lucide-*',
279
+ // next/font mints these to carry a font-family custom property. The rule is
280
+ // emitted by next/font's own pipeline, not by anything in this repo.
281
+ '__variable_*',
282
+ '__className_*',
283
+ // @hanzo/ui, backends/gui/button.js `buttonVariants` and backends/gui/badge.js
284
+ // `badgeVariants`. core/cn.cjs states the contract: "There is no Tailwind
285
+ // conflict-resolution step because there are no Tailwind utilities to resolve:
286
+ // styling lives in gui style props and the token scale, and class names are
287
+ // only stable handles (`hanzo-button`, `hz-mono`) a host may select on."
288
+ // The look comes from gui props on the same element.
289
+ 'hanzo-button',
290
+ 'hanzo-button--*',
291
+ 'hanzo-badge',
292
+ 'hanzo-badge--*',
293
+ ]
294
+ // NOT allowed, and deliberately: `hz-mono`, `hz-tnum`, `hz-row`. The same
295
+ // comment calls them handles, but gui-config.js says who writes the rule —
296
+ // "The host self-hosts both faces (its own fonts.css) — this only names them."
297
+ // They are @hanzo/ui's INTERFACE to the host, so an app that stamps them and
298
+ // defines nothing renders ids and figures in the proportional face with
299
+ // proportional digits. That is a miss, and it should read as one.
300
+
301
+ const globToRe = (g) =>
302
+ new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$')
303
+
304
+ export function loadConfig(cwd) {
305
+ for (const name of ['gui-css-check.json', '.gui-css-check.json']) {
306
+ const p = join(cwd, name)
307
+ if (existsSync(p)) return { ...JSON.parse(readFileSync(p, 'utf8')), _from: p }
308
+ }
309
+ const pkgPath = join(cwd, 'package.json')
310
+ if (existsSync(pkgPath)) {
311
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
312
+ if (pkg['gui-css-check']) return { ...pkg['gui-css-check'], _from: pkgPath }
313
+ }
314
+ return {}
315
+ }
316
+
317
+ // --------------------------------------------------------------------- check
318
+
319
+ /**
320
+ * @returns {{pages: Array, used: Set, missing: Map, sheets: Map, bytes: object}}
321
+ */
322
+ export function check({ roots, allow = [], extraCss = [] }) {
323
+ const { pages, sheets } = collect(roots)
324
+ const byBase = indexByBase(sheets)
325
+ const allowRe = [...DEFAULT_ALLOW, ...allow].map(globToRe)
326
+ const allowed = (c) => allowRe.some((re) => re.test(c))
327
+
328
+ const shared = new Set()
329
+ for (const p of extraCss) for (const c of definedClasses(readFileSync(p, 'utf8'))) shared.add(c)
330
+
331
+ // Sheets are parsed once and reused; a page just unions the ones it links.
332
+ const parsed = new Map()
333
+ const classesOf = (path, css) => {
334
+ if (!parsed.has(path)) parsed.set(path, definedClasses(css))
335
+ return parsed.get(path)
336
+ }
337
+
338
+ const results = []
339
+ const allUsed = new Set()
340
+ const missing = new Map() // class -> [page, ...]
341
+ const linkedOnce = new Map() // path -> bytes, counted once however many pages link it
342
+ let inlineBytes = 0
343
+
344
+ for (const page of pages) {
345
+ const html = readFileSync(page, 'utf8')
346
+ const used = usedClasses(html)
347
+ const defined = new Set(shared)
348
+
349
+ for (const css of inlineStyles(html)) {
350
+ inlineBytes += Buffer.byteLength(css)
351
+ for (const c of definedClasses(css)) defined.add(c)
352
+ }
353
+
354
+ const unresolved = []
355
+ const linked = []
356
+ for (const href of stylesheetHrefs(html)) {
357
+ if (/^(https?:)?\/\//i.test(href)) continue // remote sheet, not ours to verify
358
+ const hit = byBase.get(basename(href))
359
+ if (!hit) {
360
+ unresolved.push(href)
361
+ continue
362
+ }
363
+ linked.push(hit.path)
364
+ linkedOnce.set(hit.path, Buffer.byteLength(hit.css))
365
+ for (const c of classesOf(hit.path, hit.css)) defined.add(c)
366
+ }
367
+
368
+ const miss = []
369
+ for (const c of used) {
370
+ allUsed.add(c)
371
+ if (defined.has(c) || allowed(c)) continue
372
+ miss.push(c)
373
+ if (!missing.has(c)) missing.set(c, [])
374
+ missing.get(c).push(page)
375
+ }
376
+ results.push({ page, used: used.size, missing: miss.sort(), linked, unresolved })
377
+ }
378
+
379
+ // A document with no classes at all proves nothing: it is either an empty
380
+ // shell a client will fill in, or a render that failed. Counting it as a
381
+ // pass is how a checker becomes decoration.
382
+ const empty = results.filter((r) => !r.used)
383
+ // Same trap one step along: an app whose routes are all server-rendered
384
+ // prerenders nothing but the framework's own error pages, and scoring THOSE
385
+ // says nothing whatever about the app. Seen on hanzo.id, where the single
386
+ // checked page was Next's built-in 500.
387
+ const boilerplate = results.filter((r) => BOILERPLATE.test(basename(r.page)))
388
+
389
+ return {
390
+ results,
391
+ pages,
392
+ sheets,
393
+ empty,
394
+ boilerplate,
395
+ used: allUsed,
396
+ missing,
397
+ bytes: {
398
+ // cached once by the browser, however many pages link it
399
+ static: [...linkedOnce.values()].reduce((a, b) => a + b, 0),
400
+ staticFiles: linkedOnce.size,
401
+ // re-sent with every single document and cacheable by nobody — the
402
+ // number a static extractor exists to drive to zero
403
+ inlinePerPage: pages.length ? Math.round(inlineBytes / pages.length) : 0,
404
+ inlineTotal: inlineBytes,
405
+ },
406
+ }
407
+ }
408
+
409
+ // ------------------------------------------------------------------------ cli
410
+
411
+ const HELP = `gui-css-check — fail the build when the markup uses a class the CSS never defines
412
+
413
+ gui-css-check [dir|file ...] [options]
414
+
415
+ With no path it looks for .next, dist, out, build, storybook-static in cwd.
416
+
417
+ --render <url> render a running route in a browser and check THAT (repeatable).
418
+ The only honest measurement for a client-rendered app, whose
419
+ built HTML is an empty shell. Needs playwright in the app.
420
+ --css <file> an extra sheet every page gets (repeatable)
421
+ --allow <glob> a class pattern that needs no rule (repeatable)
422
+ --json machine-readable result on stdout
423
+ --quiet only print on failure
424
+
425
+ Allowances also load from gui-css-check.json or a "gui-css-check" key in
426
+ package.json: { "allow": ["swiper-*"] }
427
+ `
428
+
429
+ const DEFAULT_ROOTS = ['.next', 'dist', 'out', 'build', 'storybook-static']
430
+
431
+ /** Utility-framework shapes: `px-3`, `text-white/40`, `hover:bg-white/[0.06]`. */
432
+ const UTILITY =
433
+ /^(-?[a-z][\w-]*:)*-?((p|m|px|py|pt|pb|pl|pr|mx|my|mt|mb|ml|mr|w|h|z|gap|text|bg|border|ring|max|min|top|left|right|bottom|inset|col|row|order|basis|space|leading|tracking|font|opacity|shadow|rounded|aspect|overflow|translate|scale|rotate|duration|delay|ease|from|via|to|fill|stroke|divide|placeholder|outline|decoration|indent|justify|items|content|self|place|object|cursor|backdrop|prose|grid|flex)(-[\w./[\]#%()-]+)?|(flex|grid|block|inline|hidden|absolute|relative|fixed|sticky|static|truncate|italic|underline|uppercase|antialiased|container|prose|sr-only|isolate|visible|invisible))$/
434
+
435
+ /**
436
+ * Misses are grouped by CAUSE, because the fix differs completely and a flat
437
+ * list of 300 class names tells you nothing about which one you have.
438
+ */
439
+ const KINDS = [
440
+ {
441
+ test: (c) => c.startsWith('_'),
442
+ title: 'gui atomic classes — the atomic sheet was never authored',
443
+ cause:
444
+ ` Nothing produced these rules. Exactly one of the two paths has to run:\n` +
445
+ ` · GuiProvider injects at runtime — do NOT set disableInjectCSS, or\n` +
446
+ ` · a compiler plugin extracts them at build time —\n` +
447
+ ` @hanzogui/next-plugin withGui() / @hanzogui/vite-plugin gui().\n` +
448
+ ` disableInjectCSS with no plugin configured leaves nobody writing it.`,
449
+ },
450
+ {
451
+ test: (c) => UTILITY.test(c),
452
+ title: 'utility-framework classes — no such framework is installed',
453
+ cause:
454
+ ` These are Tailwind-shaped, and nothing in this build compiles them.\n` +
455
+ ` They have never rendered. Delete them and use gui props instead;\n` +
456
+ ` adding Tailwind back is not the fix.`,
457
+ },
458
+ {
459
+ test: () => true,
460
+ title: 'authored classes with no stylesheet behind them',
461
+ cause:
462
+ ` Someone wrote these class names and no delivered sheet defines them.\n` +
463
+ ` Either ship the stylesheet that styles them or drop the class.`,
464
+ },
465
+ ]
466
+
467
+ const num = (n) => n.toLocaleString('en-US')
468
+ const kb = (n) => `${num(Math.round(n / 1024))} KB`
469
+
470
+ async function main(argv) {
471
+ const roots = []
472
+ const extraCss = []
473
+ const allow = []
474
+ const urls = []
475
+ let json = false
476
+ let quiet = false
477
+ for (let i = 0; i < argv.length; i++) {
478
+ const a = argv[i]
479
+ if (a === '--help' || a === '-h') return console.log(HELP), 0
480
+ else if (a === '--json') json = true
481
+ else if (a === '--quiet') quiet = true
482
+ else if (a === '--css') extraCss.push(argv[++i])
483
+ else if (a === '--allow') allow.push(argv[++i])
484
+ else if (a === '--render') urls.push(argv[++i])
485
+ else if (a.startsWith('-')) return console.error(`unknown option ${a}\n${HELP}`), 2
486
+ else roots.push(a)
487
+ }
488
+
489
+ const cwd = process.cwd()
490
+ const cfg = loadConfig(cwd)
491
+ const wanted = [...urls, ...(urls.length ? [] : cfg.render ?? [])]
492
+
493
+ let found = roots
494
+ if (wanted.length) {
495
+ const dir = mkdtempSync(join(tmpdir(), 'gui-css-check-'))
496
+ try {
497
+ found = await render(wanted, { dir })
498
+ } catch (err) {
499
+ console.error(`gui-css-check: ${err.message}`)
500
+ return 2
501
+ }
502
+ } else if (!found.length) {
503
+ found = DEFAULT_ROOTS.filter((d) => existsSync(join(cwd, d)))
504
+ }
505
+
506
+ if (!found.length) {
507
+ console.error(
508
+ `gui-css-check: no built output.\n` +
509
+ ` Looked for ${DEFAULT_ROOTS.join(', ')} in ${cwd}\n` +
510
+ ` Build first, or pass the directory holding the rendered HTML and CSS,\n` +
511
+ ` or --render <url> against a running server.`
512
+ )
513
+ return 2
514
+ }
515
+ for (const r of found)
516
+ if (!existsSync(r)) return console.error(`gui-css-check: no such path: ${r}`), 2
517
+
518
+ const res = check({
519
+ roots: found,
520
+ allow: [...(cfg.allow ?? []), ...allow],
521
+ extraCss: [...(cfg.css ?? []), ...extraCss],
522
+ })
523
+
524
+ const nothingMeasured =
525
+ !res.pages.length ||
526
+ res.empty.length === res.pages.length ||
527
+ res.boilerplate.length === res.pages.length
528
+ if (nothingMeasured) {
529
+ console.error(
530
+ `gui-css-check: ` +
531
+ (!res.pages.length
532
+ ? `found no rendered HTML under ${found.join(', ')}.\n`
533
+ : res.empty.length === res.pages.length
534
+ ? `all ${res.pages.length} document(s) under ${found.join(', ')} use ZERO classes.\n`
535
+ : `the only prerendered page(s) here are the framework's own error pages\n` +
536
+ ` (${res.boilerplate.map((r) => basename(r.page)).join(', ')}).\n`) +
537
+ ` Nothing about this app was checked, so nothing is proven — that is a\n` +
538
+ ` failure, not a pass. Either the routes are all server-rendered or the app\n` +
539
+ ` renders on the client. Check the real thing:\n` +
540
+ ` gui-css-check --render http://localhost:3000/`
541
+ )
542
+ return 2
543
+ }
544
+
545
+ const unresolved = res.results.flatMap((r) => r.unresolved.map((h) => [r.page, h]))
546
+ const total = res.used.size
547
+ const missCount = res.missing.size
548
+ const covered = total - missCount
549
+ const pct = total ? (covered / total) * 100 : 100
550
+
551
+ if (json) {
552
+ console.log(
553
+ JSON.stringify(
554
+ {
555
+ pages: res.pages.length,
556
+ sheets: res.sheets.size,
557
+ bytes: res.bytes,
558
+ used: total,
559
+ covered,
560
+ coverage: Number(pct.toFixed(2)),
561
+ missing: Object.fromEntries(
562
+ [...res.missing].map(([c, pages]) => [c, pages.map((p) => relative(cwd, p))])
563
+ ),
564
+ unresolved: unresolved.map(([p, h]) => ({ page: relative(cwd, p), href: h })),
565
+ },
566
+ null,
567
+ 2
568
+ )
569
+ )
570
+ return missCount || unresolved.length ? 1 : 0
571
+ }
572
+
573
+ const ok = !missCount && !unresolved.length
574
+ if (!ok || !quiet) {
575
+ const b = res.bytes
576
+ console.log(
577
+ `gui-css-check ${num(res.pages.length)} page(s) · ` +
578
+ `${b.staticFiles} cached sheet(s), ${kb(b.static)} · ` +
579
+ `${kb(b.inlinePerPage)} inline per document\n` +
580
+ ` ${num(covered)}/${num(total)} classes covered (${pct.toFixed(1)}%)` +
581
+ // 100% of nothing is still 100%, and that is the shape of a page that
582
+ // failed to render. Say so on the same line as the score.
583
+ (res.empty.length ? `\n ${num(res.empty.length)} page(s) use no classes at all` : '')
584
+ )
585
+ }
586
+
587
+ if (unresolved.length) {
588
+ console.error(`\nFAIL ${unresolved.length} stylesheet link(s) point at a file that is not there:`)
589
+ for (const [page, href] of unresolved.slice(0, 20))
590
+ console.error(` ${relative(cwd, page)} -> ${href}`)
591
+ }
592
+
593
+ if (missCount) {
594
+ console.error(
595
+ `\nFAIL ${num(missCount)} of ${num(total)} classes in the markup have NO rule in any\n` +
596
+ ` stylesheet these pages deliver. They render with no styling at all.`
597
+ )
598
+ const bins = KINDS.map(() => [])
599
+ for (const c of res.missing.keys()) bins[KINDS.findIndex((k) => k.test(c))].push(c)
600
+ for (const [i, kind] of KINDS.entries()) {
601
+ const classes = bins[i]
602
+ if (!classes.length) continue
603
+ const where = relative(cwd, res.missing.get(classes[0])[0])
604
+ console.error(`\n ${classes.length}× ${kind.title}`)
605
+ console.error(classes.slice(0, 10).map((c) => ` ${c}`).join('\n'))
606
+ if (classes.length > 10) console.error(` … ${classes.length - 10} more`)
607
+ console.error(` first in ${where}\n${kind.cause}`)
608
+ }
609
+ console.error(
610
+ ` A class that really is styled by something this check cannot see belongs\n` +
611
+ ` in gui-css-check.json {"allow": [...]}, with a note saying who styles it.\n`
612
+ )
613
+ }
614
+
615
+ return ok ? 0 : 1
616
+ }
617
+
618
+ const invokedDirectly =
619
+ process.argv[1] &&
620
+ (import.meta.url === `file://${process.argv[1]}` ||
621
+ resolve(process.argv[1]).endsWith(`${sep}css-check.mjs`) ||
622
+ basename(process.argv[1]) === 'gui-css-check')
623
+
624
+ if (invokedDirectly) process.exit(await main(process.argv.slice(2)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/gui",
3
- "version": "8.0.0",
3
+ "version": "8.0.2",
4
4
  "gitHead": "a49cc7ea6b93ba384e77a4880ae48ac4a5635c14",
5
5
  "description": "Style and UI for React (web and native) meet an optimizing compiler",
6
6
  "repository": {
@@ -9,6 +9,25 @@
9
9
  "directory": "pkgs/ui/hanzogui"
10
10
  },
11
11
  "source": "src/index.ts",
12
+ "files": [
13
+ "bundle-native.mjs",
14
+ "css-check.mjs",
15
+ "dist",
16
+ "hanzogui.config.ts",
17
+ "linear-gradient",
18
+ "native-test.cjs",
19
+ "native-test.d.ts",
20
+ "native-test",
21
+ "native.cjs",
22
+ "native.d.ts",
23
+ "native",
24
+ "react-native-web.cjs",
25
+ "react-native-web",
26
+ "src",
27
+ "types",
28
+ "web.cjs",
29
+ "web"
30
+ ],
12
31
  "type": "module",
13
32
  "sideEffects": [
14
33
  "setup.*"
@@ -16,8 +35,12 @@
16
35
  "main": "dist/cjs",
17
36
  "module": "dist/esm",
18
37
  "types": "./types/index.d.ts",
38
+ "bin": {
39
+ "gui-css-check": "./css-check.mjs"
40
+ },
19
41
  "exports": {
20
42
  "./package.json": "./package.json",
43
+ "./css-check": "./css-check.mjs",
21
44
  ".": {
22
45
  "types": "./types/index.d.ts",
23
46
  "react-native": "./dist/esm/index.native.js",
@@ -74,71 +97,73 @@
74
97
  "clean": "hanzogui-build clean",
75
98
  "clean:build": "hanzogui-build clean:build",
76
99
  "check": "bun run check-circular-deps",
77
- "check-circular-deps": "npx madge --circular ./src/index.ts"
100
+ "check-circular-deps": "npx madge --circular ./src/index.ts",
101
+ "test": "node --test css-check.test.mjs"
78
102
  },
79
103
  "dependencies": {
80
- "@hanzogui/accordion": "8.0.0",
81
- "@hanzogui/adapt": "8.0.0",
82
- "@hanzogui/alert-dialog": "8.0.0",
104
+ "@hanzogui/accordion": "8.0.1",
105
+ "@hanzogui/adapt": "8.0.1",
106
+ "@hanzogui/alert-dialog": "8.0.1",
83
107
  "@hanzogui/animate": "8.0.0",
84
108
  "@hanzogui/animate-presence": "8.0.0",
85
- "@hanzogui/avatar": "8.0.0",
86
- "@hanzogui/button": "8.0.0",
87
- "@hanzogui/card": "8.0.0",
88
- "@hanzogui/checkbox": "8.0.0",
89
- "@hanzogui/collapsible": "8.0.0",
109
+ "@hanzogui/avatar": "8.0.1",
110
+ "@hanzogui/button": "8.0.1",
111
+ "@hanzogui/card": "8.0.1",
112
+ "@hanzogui/checkbox": "8.0.1",
113
+ "@hanzogui/collapsible": "8.0.1",
114
+ "@hanzogui/component-helpers": "8.0.0",
90
115
  "@hanzogui/compose-refs": "8.0.0",
91
116
  "@hanzogui/constants": "8.0.0",
92
- "@hanzogui/context-menu": "8.0.0",
93
- "@hanzogui/core": "8.0.0",
117
+ "@hanzogui/context-menu": "8.0.1",
118
+ "@hanzogui/core": "8.0.1",
94
119
  "@hanzogui/create-context": "8.0.0",
95
- "@hanzogui/create-menu": "8.0.0",
96
- "@hanzogui/dialog": "8.0.0",
120
+ "@hanzogui/create-menu": "8.0.1",
121
+ "@hanzogui/dialog": "8.0.1",
97
122
  "@hanzogui/element": "8.0.0",
98
- "@hanzogui/elements": "8.0.0",
123
+ "@hanzogui/elements": "8.0.1",
99
124
  "@hanzogui/fake-react-native": "8.0.0",
100
125
  "@hanzogui/focusable": "8.0.0",
101
- "@hanzogui/form": "8.0.0",
126
+ "@hanzogui/font-size": "8.0.1",
127
+ "@hanzogui/form": "8.0.1",
102
128
  "@hanzogui/get-button-sized": "8.0.0",
103
129
  "@hanzogui/get-font-sized": "8.0.0",
104
130
  "@hanzogui/get-token": "8.0.0",
105
- "@hanzogui/group": "8.0.0",
106
- "@hanzogui/component-helpers": "8.0.0",
107
- "@hanzogui/image": "8.0.0",
108
- "@hanzogui/input": "8.0.0",
131
+ "@hanzogui/group": "8.0.1",
132
+ "@hanzogui/image": "8.0.1",
133
+ "@hanzogui/input": "8.0.1",
109
134
  "@hanzogui/label": "8.0.0",
110
- "@hanzogui/linear-gradient": "8.0.0",
111
- "@hanzogui/list-item": "8.0.0",
112
- "@hanzogui/menu": "8.0.0",
135
+ "@hanzogui/linear-gradient": "8.0.1",
136
+ "@hanzogui/list-item": "8.0.1",
137
+ "@hanzogui/menu": "8.0.1",
113
138
  "@hanzogui/polyfill-dev": "8.0.0",
114
- "@hanzogui/popover": "8.0.0",
115
- "@hanzogui/popper": "8.0.0",
116
- "@hanzogui/portal": "8.0.0",
117
- "@hanzogui/progress": "8.0.0",
118
- "@hanzogui/radio-group": "8.0.0",
139
+ "@hanzogui/popover": "8.0.1",
140
+ "@hanzogui/popper": "8.0.1",
141
+ "@hanzogui/portal": "8.0.1",
142
+ "@hanzogui/progress": "8.0.1",
143
+ "@hanzogui/radio-group": "8.0.1",
119
144
  "@hanzogui/react-native-media-driver": "8.0.0",
120
- "@hanzogui/scroll-view": "8.0.0",
121
- "@hanzogui/select": "8.0.0",
122
- "@hanzogui/separator": "8.0.0",
123
- "@hanzogui/shapes": "8.0.0",
124
- "@hanzogui/sheet": "8.0.0",
125
- "@hanzogui/slider": "8.0.0",
145
+ "@hanzogui/scroll-view": "8.0.1",
146
+ "@hanzogui/select": "8.0.1",
147
+ "@hanzogui/separator": "8.0.1",
148
+ "@hanzogui/shapes": "8.0.1",
149
+ "@hanzogui/sheet": "8.0.1",
150
+ "@hanzogui/slider": "8.0.1",
126
151
  "@hanzogui/spacer": "8.0.0",
127
- "@hanzogui/spinner": "8.0.0",
128
- "@hanzogui/stacks": "8.0.0",
129
- "@hanzogui/switch": "8.0.0",
130
- "@hanzogui/tabs": "8.0.0",
152
+ "@hanzogui/spinner": "8.0.1",
153
+ "@hanzogui/stacks": "8.0.1",
154
+ "@hanzogui/switch": "8.0.1",
155
+ "@hanzogui/tabs": "8.0.1",
131
156
  "@hanzogui/text": "8.0.0",
132
157
  "@hanzogui/theme": "8.0.0",
133
- "@hanzogui/toast": "8.0.0",
134
- "@hanzogui/toggle-group": "8.0.0",
135
- "@hanzogui/tooltip": "8.0.0",
158
+ "@hanzogui/toast": "8.0.1",
159
+ "@hanzogui/toggle-group": "8.0.1",
160
+ "@hanzogui/tooltip": "8.0.1",
136
161
  "@hanzogui/use-controllable-state": "8.0.0",
137
162
  "@hanzogui/use-debounce": "8.0.0",
138
163
  "@hanzogui/use-force-update": "8.0.0",
139
164
  "@hanzogui/use-window-dimensions": "8.0.0",
140
165
  "@hanzogui/visually-hidden": "8.0.0",
141
- "@hanzogui/font-size": "8.0.0",
166
+ "@hanzogui/web": "8.0.0",
142
167
  "@hanzogui/z-index-stack": "8.0.0"
143
168
  },
144
169
  "devDependencies": {
@@ -155,4 +180,4 @@
155
180
  ],
156
181
  "module:jsx": "dist/jsx",
157
182
  "removeSideEffects": true
158
- }
183
+ }
@@ -5,11 +5,5 @@ export interface AnchorExtraProps {
5
5
  rel?: string;
6
6
  }
7
7
  export type AnchorProps = SizableTextProps & AnchorExtraProps;
8
- export declare const Anchor: import("@hanzogui/core").GuiComponent<Omit<import("@hanzogui/core").GetFinalProps<import("@hanzogui/core").TextNonStyleProps, import("@hanzogui/core").TextStylePropsBase, {
9
- unstyled?: boolean | undefined;
10
- size?: import("@hanzogui/core").FontSizeTokens | undefined;
11
- }>, keyof AnchorExtraProps> & AnchorExtraProps, import("@hanzogui/core").GuiTextElement, import("@hanzogui/core").TextNonStyleProps & AnchorExtraProps, import("@hanzogui/core").TextStylePropsBase, {
12
- unstyled?: boolean | undefined;
13
- size?: import("@hanzogui/core").FontSizeTokens | undefined;
14
- }, import("@hanzogui/core").StaticConfigPublic>;
8
+ export declare const Anchor: any;
15
9
  //# sourceMappingURL=Anchor.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Anchor.d.ts","sourceRoot":"","sources":["../../src/views/Anchor.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAItD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,GAAG,CAAC,EAAE,MAAM,CAAA;CACb;AAED,MAAM,MAAM,WAAW,GAAG,gBAAgB,GAAG,gBAAgB,CAAA;AAQ7D,eAAO,MAAM,MAAM;;;;;;+CAuBlB,CAAA"}
1
+ {"version":3,"file":"Anchor.d.ts","sourceRoot":"","sources":["../../src/views/Anchor.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAItD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,GAAG,CAAC,EAAE,MAAM,CAAA;CACb;AAED,MAAM,MAAM,WAAW,GAAG,gBAAgB,GAAG,gBAAgB,CAAA;AAQ7D,eAAO,MAAM,MAAM,KAuBlB,CAAA"}
@@ -1,2 +1,2 @@
1
- export declare const EnsureFlexed: import("@hanzogui/core").GuiComponent<import("@hanzogui/core").TamaDefer, import("@hanzogui/core").GuiTextElement, import("@hanzogui/core").RNGuiTextNonStyleProps, import("@hanzogui/core").TextStylePropsBase, {}, import("@hanzogui/core").StaticConfigPublic>;
1
+ export declare const EnsureFlexed: any;
2
2
  //# sourceMappingURL=EnsureFlexed.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"EnsureFlexed.d.ts","sourceRoot":"","sources":["../../src/views/EnsureFlexed.tsx"],"names":[],"mappings":"AAIA,eAAO,MAAM,YAAY,mQAQvB,CAAA"}
1
+ {"version":3,"file":"EnsureFlexed.d.ts","sourceRoot":"","sources":["../../src/views/EnsureFlexed.tsx"],"names":[],"mappings":"AAIA,eAAO,MAAM,YAAY,KAQvB,CAAA"}
@@ -1,8 +1,4 @@
1
1
  import type { GetProps } from '@hanzogui/core';
2
- export declare const Fieldset: import("@hanzogui/core").GuiComponent<import("@hanzogui/core").TamaDefer, import("@hanzogui/core").GuiElement, import("@hanzogui/core").RNGuiViewNonStyleProps, import("@hanzogui/core").StackStyleBase, {
3
- elevation?: number | import("@hanzogui/core").SizeTokens | undefined;
4
- fullscreen?: boolean | undefined;
5
- horizontal?: boolean | undefined;
6
- }, import("@hanzogui/core").StaticConfigPublic>;
2
+ export declare const Fieldset: any;
7
3
  export type FieldsetProps = GetProps<typeof Fieldset>;
8
4
  //# sourceMappingURL=Fieldset.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Fieldset.d.ts","sourceRoot":"","sources":["../../src/views/Fieldset.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAI9C,eAAO,MAAM,QAAQ;;;;+CAenB,CAAA;AAEF,MAAM,MAAM,aAAa,GAAG,QAAQ,CAAC,OAAO,QAAQ,CAAC,CAAA"}
1
+ {"version":3,"file":"Fieldset.d.ts","sourceRoot":"","sources":["../../src/views/Fieldset.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAI9C,eAAO,MAAM,QAAQ,KAenB,CAAA;AAEF,MAAM,MAAM,aAAa,GAAG,QAAQ,CAAC,OAAO,QAAQ,CAAC,CAAA"}
@@ -1,4 +1,2 @@
1
- export declare const Text: import("@hanzogui/core").GuiComponent<import("@hanzogui/core").TamaDefer, import("@hanzogui/core").GuiTextElement, import("@hanzogui/core").RNGuiTextNonStyleProps, import("@hanzogui/core").TextStylePropsBase, {
2
- unstyled?: boolean | undefined;
3
- }, import("@hanzogui/core").StaticConfigPublic>;
1
+ export declare const Text: any;
4
2
  //# sourceMappingURL=Text.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Text.d.ts","sourceRoot":"","sources":["../../src/views/Text.tsx"],"names":[],"mappings":"AAEA,eAAO,MAAM,IAAI;;+CAYf,CAAA"}
1
+ {"version":3,"file":"Text.d.ts","sourceRoot":"","sources":["../../src/views/Text.tsx"],"names":[],"mappings":"AAEA,eAAO,MAAM,IAAI,KAYf,CAAA"}
@@ -1,5 +1,2 @@
1
- export declare const VisuallyHidden: import("@hanzogui/core").GuiComponent<import("@hanzogui/core").TamaDefer, import("@hanzogui/core").GuiElement, import("@hanzogui/core").RNGuiViewNonStyleProps, import("@hanzogui/core").StackStyleBase, {
2
- visible?: boolean | undefined;
3
- preserveDimensions?: boolean | undefined;
4
- }, import("@hanzogui/core").StaticConfigPublic>;
1
+ export declare const VisuallyHidden: any;
5
2
  //# sourceMappingURL=VisuallyHidden.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"VisuallyHidden.d.ts","sourceRoot":"","sources":["../../src/views/VisuallyHidden.tsx"],"names":[],"mappings":"AAEA,eAAO,MAAM,cAAc;;;+CAgCzB,CAAA"}
1
+ {"version":3,"file":"VisuallyHidden.d.ts","sourceRoot":"","sources":["../../src/views/VisuallyHidden.tsx"],"names":[],"mappings":"AAEA,eAAO,MAAM,cAAc,KAgCzB,CAAA"}
@@ -1,4 +0,0 @@
1
- $ hanzogui-build
2
- built hanzogui in 2162 ms
3
- Running afterBuild script...
4
- afterBuild completed in 499 ms
package/tsconfig.json DELETED
@@ -1,171 +0,0 @@
1
- {
2
- "extends": "../../../tsconfig.json",
3
- "compilerOptions": {
4
- "composite": true
5
- },
6
- "exclude": [
7
- "hanzogui.config.ts",
8
- "test",
9
- "dist",
10
- "types",
11
- "./linear-gradient",
12
- "./native.d.ts",
13
- "./native-test.d.ts",
14
- "**/__tests__"
15
- ],
16
- "references": [
17
- {
18
- "path": "../../core/compose-refs"
19
- },
20
- {
21
- "path": "../../core/constants"
22
- },
23
- {
24
- "path": "../../core/core"
25
- },
26
- {
27
- "path": "../../core/create-context"
28
- },
29
- {
30
- "path": "../../core/font-size"
31
- },
32
- {
33
- "path": "../../core/get-button-sized"
34
- },
35
- {
36
- "path": "../../core/get-font-sized"
37
- },
38
- {
39
- "path": "../../core/get-token"
40
- },
41
- {
42
- "path": "../../core/component-helpers"
43
- },
44
- {
45
- "path": "../../core/react-native-media-driver"
46
- },
47
- {
48
- "path": "../../core/theme"
49
- },
50
- {
51
- "path": "../../core/use-controllable-state"
52
- },
53
- {
54
- "path": "../../core/use-debounce"
55
- },
56
- {
57
- "path": "../../core/use-force-update"
58
- },
59
- {
60
- "path": "../../core/use-window-dimensions"
61
- },
62
- {
63
- "path": "../../element"
64
- },
65
- {
66
- "path": "../accordion"
67
- },
68
- {
69
- "path": "../adapt"
70
- },
71
- {
72
- "path": "../alert-dialog"
73
- },
74
- {
75
- "path": "../animate-presence"
76
- },
77
- {
78
- "path": "../avatar"
79
- },
80
- {
81
- "path": "../button"
82
- },
83
- {
84
- "path": "../card"
85
- },
86
- {
87
- "path": "../checkbox"
88
- },
89
- {
90
- "path": "../dialog"
91
- },
92
- {
93
- "path": "../elements"
94
- },
95
- {
96
- "path": "../focusable"
97
- },
98
- {
99
- "path": "../form"
100
- },
101
- {
102
- "path": "../group"
103
- },
104
- {
105
- "path": "../image"
106
- },
107
- {
108
- "path": "../label"
109
- },
110
- {
111
- "path": "../linear-gradient"
112
- },
113
- {
114
- "path": "../list-item"
115
- },
116
- {
117
- "path": "../popover"
118
- },
119
- {
120
- "path": "../popper"
121
- },
122
- {
123
- "path": "../portal"
124
- },
125
- {
126
- "path": "../progress"
127
- },
128
- {
129
- "path": "../radio-group"
130
- },
131
- {
132
- "path": "../scroll-view"
133
- },
134
- {
135
- "path": "../select"
136
- },
137
- {
138
- "path": "../separator"
139
- },
140
- {
141
- "path": "../shapes"
142
- },
143
- {
144
- "path": "../sheet"
145
- },
146
- {
147
- "path": "../slider"
148
- },
149
- {
150
- "path": "../stacks"
151
- },
152
- {
153
- "path": "../switch"
154
- },
155
- {
156
- "path": "../tabs"
157
- },
158
- {
159
- "path": "../text"
160
- },
161
- {
162
- "path": "../toggle-group"
163
- },
164
- {
165
- "path": "../tooltip"
166
- },
167
- {
168
- "path": "../visually-hidden"
169
- }
170
- ]
171
- }