mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-28 07:32:52 +00:00
195 lines
9.4 KiB
JavaScript
195 lines
9.4 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* The tailwind surface, as a number that may only go down.
|
|
*
|
|
* node scripts/tailwind-floor.mjs # print the count
|
|
* node scripts/tailwind-floor.mjs --write # record it in tailwind.json
|
|
* node scripts/tailwind-floor.mjs --check # fail on any increase
|
|
*
|
|
* This site is migrating off Tailwind onto @hanzo/gui — `components/home/band.tsx`
|
|
* is the shape the other side looks like: gui primitives for layout, token-valued
|
|
* style objects for the rest, and @hanzo/ui's marketing components for type.
|
|
* The landing page is already across.
|
|
*
|
|
* IT FAILS ON SLACK TOO, not only on growth — the discipline
|
|
* `hanzo-design.allow.json` already holds one step above this in hanzo.yml. A
|
|
* floor left above the real count is room for the next author to regress into
|
|
* without anything noticing, so a commit that converts a file lowers its own
|
|
* number in the same diff and the progress is visible in the review rather than
|
|
* inferred from a green check.
|
|
*
|
|
* WHY A RATCHET AND NOT A DEADLINE. 56,962 uses across 431 files is a program,
|
|
* not a commit, and it is being worked by more than one person at a time. A
|
|
* migration measured only at its end has no way to notice a page added in the
|
|
* old idiom while it runs, and that is how one of these stalls: the converted
|
|
* files fall to zero and the total does not move, because new tailwind arrived
|
|
* behind the work. The number here can only fall, so every increment is
|
|
* monotone and a regression names the directory it landed in.
|
|
*
|
|
* WHAT COUNTS. Every class token in a `className` that this app does not declare
|
|
* itself. Ours are the names our own stylesheets declare, read from them by
|
|
* scripts/names.mjs; a package's arrive with the package and are known by its
|
|
* prefix (`hz-` for @hanzo/ui and @hanzo/composer, `is_` / `t_` / `_` / `font_`
|
|
* for gui); and there are two document markers (`dark`, `light`).
|
|
* Everything else in a className on this site is a Tailwind utility — measured,
|
|
* the remaining distinct tokens are `flex`, `items-center`, `text-sm`, `mx-auto`
|
|
* and their kin, with no third vocabulary among them.
|
|
*
|
|
* A token carrying `${` is skipped rather than guessed at: it is a template hole
|
|
* whose value is decided at run time, and counting the literal around it would
|
|
* report a number that changes with how the expression is written.
|
|
*/
|
|
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'
|
|
import { dirname, join, relative } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { declared, names, sheet } from './names.mjs'
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const FLOOR = join(ROOT, 'tailwind.json')
|
|
const ROOTS = ['components', 'app']
|
|
|
|
/** Names this app declares itself; everything else is Tailwind.
|
|
*
|
|
* Read from the stylesheets rather than matched by prefix. `OURS` was
|
|
* `/^(hz-|is_|t_|_|font_)/` while our own vocabulary carried a prefix, and the
|
|
* day it dropped one, every role class on this site — `action`, `tap`, `title`,
|
|
* `lede` — would have stopped looking like ours and started counting as
|
|
* Tailwind. The floor would have risen by thousands with no tailwind added and
|
|
* `--check` would have failed on a rename. gui's names still ARRIVE as prefixes,
|
|
* because they are gui's and a library's classes carry its name. */
|
|
const OURS = names()
|
|
/* Names that arrive WITH a package, recognized by the prefix a package's classes
|
|
carry. `hz-` is still exactly right here and is not a leftover: `hz-mono` and
|
|
`hz-tnum` are stamped by @hanzo/ui's own components onto ours, and this repo
|
|
deleted its copies of those rules when the package started shipping them — so
|
|
they are worn here and declared nowhere in this repo, and `names()` cannot see
|
|
them. Reading them as Tailwind would put two of the component library's own
|
|
classes on the migration's bill. */
|
|
const PACKAGE = /^(hz-|is_|t_|_|font_)/
|
|
const MARKERS = new Set(['dark', 'light'])
|
|
|
|
/* The reader stays this file's own, and deliberately so. scripts/names.mjs has a
|
|
stricter one that consumers.mjs needs, and swapping it in here would move this
|
|
number by ~1,000 without a line of tailwind leaving the site: this regex takes
|
|
the first quoted run after `className=` and, on a ternary, also collects the
|
|
`?`, `:` and `}` around it as though they were utilities. That is a real defect
|
|
and it is not this commit's — a floor is only comparable to the one recorded
|
|
beneath it, so the count changes when someone fixes the reader on purpose and
|
|
re-records it, not as a side effect of a rename. */
|
|
const CLASSNAME = /className=(?:"([^"]*)"|\{`([^`]*)`\}|\{'([^']*)'\})/g
|
|
|
|
function walk(dir, out = []) {
|
|
for (const name of readdirSync(dir)) {
|
|
const p = join(dir, name)
|
|
if (statSync(p).isDirectory()) {
|
|
if (name === 'node_modules' || name === '.next') continue
|
|
walk(p, out)
|
|
} else if (name.endsWith('.tsx')) out.push(p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Utility uses in one file.
|
|
*
|
|
* A page that ships its OWN stylesheet declares its own names, and they are
|
|
* read from it exactly as `OURS` is read from `SHEETS` (scripts/names.mjs) — same
|
|
* function, same rule, one file narrower. `/calculator` is the page that does
|
|
* it: 96 of the tokens this ratchet counted were `.ts` and its 34 children,
|
|
* the namespace its inline `<style>` block scopes the whole page under, and
|
|
* none of them was ever a utility. Reading them as Tailwind put a page that
|
|
* imports no utility vocabulary at all at the top of the migration's bill, and
|
|
* a prefix constant here would have been the same guess in fewer characters —
|
|
* right for `ts-` until the next page picks a different word. */
|
|
function count(file) {
|
|
const src = readFileSync(file, 'utf8')
|
|
const own = declared(sheet(src))
|
|
let n = 0
|
|
for (const m of src.matchAll(CLASSNAME)) {
|
|
// AN INTERPOLATION IS NOT A CLASS, AND NEITHER IS ITS INSIDE. A template
|
|
// like `col-1 ${on ? 'lit' : ''}` splits on whitespace into `?`, `:`,
|
|
// `'lit'` and `''}` — four tokens, none of them a class name, all counted.
|
|
// Guarding on `t.includes('${')` caught only the first fragment, so Vibe
|
|
// and Beluga alone put 34 punctuation marks on the migration's bill. The
|
|
// expression is dropped whole and the literal words around it still count,
|
|
// which is what the ratchet is for.
|
|
// The expression becomes a MARK rather than a space: `ts-c-${cov[i]}` is one
|
|
// token whose name is not knowable here, and splitting it to `ts-c-` would
|
|
// put a stem nobody wrote on the bill. A token carrying the mark is dropped
|
|
// whole; the literal words beside it still count.
|
|
const value = (m[1] ?? m[2] ?? m[3] ?? '').replace(/\$\{[^}]*\}/g, '\u0000')
|
|
for (const raw of value.split(/\s+/)) {
|
|
const t = raw.trim()
|
|
if (!t || t.includes('\u0000') || OURS.has(t) || own.has(t) || PACKAGE.test(t) || MARKERS.has(t))
|
|
continue
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
export function measure() {
|
|
const byDir = {}
|
|
let total = 0
|
|
let files = 0
|
|
for (const r of ROOTS) {
|
|
for (const file of walk(join(ROOT, r))) {
|
|
const n = count(file)
|
|
if (!n) continue
|
|
total += n
|
|
files++
|
|
// Two segments: `app/(marketing)` and `components/pricing` are the units a
|
|
// reader would actually take on, and a per-file map would be a diff of its
|
|
// own on every commit.
|
|
const dir = relative(ROOT, file).split('/').slice(0, 2).join('/')
|
|
byDir[dir] = (byDir[dir] ?? 0) + n
|
|
}
|
|
}
|
|
return { total, files, byDir: Object.fromEntries(Object.entries(byDir).sort((a, b) => b[1] - a[1])) }
|
|
}
|
|
|
|
const now = measure()
|
|
const mode = process.argv[2]
|
|
|
|
if (mode === '--write') {
|
|
writeFileSync(FLOOR, JSON.stringify(now, null, 2) + '\n')
|
|
console.log(`[tailwind] recorded ${now.total} uses across ${now.files} files`)
|
|
} else if (mode === '--check') {
|
|
// A MISSING floor is refused rather than treated as zero. Losing the file must
|
|
// not be a way to lose the guarantee — the same rule openapi/floor.json states
|
|
// next door, and for the same reason.
|
|
let was
|
|
try {
|
|
was = JSON.parse(readFileSync(FLOOR, 'utf8'))
|
|
} catch {
|
|
console.error(`[tailwind] ${relative(ROOT, FLOOR)} is missing. It is the floor; regenerate it deliberately with --write.`)
|
|
process.exit(1)
|
|
}
|
|
const grew = []
|
|
const slack = []
|
|
const dirs = new Set([...Object.keys(now.byDir), ...Object.keys(was.byDir)])
|
|
if (now.total > was.total) grew.push(`total ${was.total} -> ${now.total}`)
|
|
if (now.total < was.total) slack.push(`total ${was.total} -> ${now.total}`)
|
|
for (const dir of dirs) {
|
|
const before = was.byDir[dir] ?? 0
|
|
const after = now.byDir[dir] ?? 0
|
|
if (after > before) grew.push(`${dir} ${before} -> ${after}`)
|
|
if (after < before) slack.push(`${dir} ${before} -> ${after}`)
|
|
}
|
|
|
|
if (grew.length) {
|
|
console.error('[tailwind] the surface GREW:\n ' + grew.join('\n '))
|
|
console.error('\nThis site is migrating OFF Tailwind (see the header of this script).')
|
|
console.error('New work goes in the @hanzo/gui idiom — components/home/band.tsx is the shape.')
|
|
process.exit(1)
|
|
}
|
|
if (slack.length) {
|
|
console.error('[tailwind] the surface SHRANK and the floor did not follow:\n ' + slack.join('\n '))
|
|
console.error('\nLower it in the same commit — `node scripts/tailwind-floor.mjs --write` — so the')
|
|
console.error('ground you just took cannot be given back without anything noticing.')
|
|
process.exit(1)
|
|
}
|
|
console.log(`[tailwind] ${now.total} uses across ${now.files} files — at the floor.`)
|
|
} else {
|
|
console.log(JSON.stringify(now, null, 2))
|
|
}
|