Files

207 lines
8.6 KiB
JavaScript

/**
* The names this site's own stylesheets declare, and where a name can be worn.
*
* WHY THIS FILE EXISTS. Two gates ask the same question — `consumers.mjs` asks
* whether every rule has a wearer, `tailwind-floor.mjs` asks which class tokens
* are not Tailwind's — and until the prefix went they both answered it with the
* same four characters: a name was ours if it started with `hz-`. The prefix was
* never the fact; it was a proxy for one, and the fact is that this repo's
* stylesheets declare the name. Now that the proxy is gone the fact has to be
* read directly, and it is read in ONE place so the two gates cannot drift into
* disagreeing about what "ours" means.
*
* `app/gui.css` is deliberately not a source: it is generated from lib/gui.ts by
* scripts/gen-gui-css.mjs and the names in it are gui's, reached by their own
* prefixes (`is_` / `t_` / `_` / `font_`). Reading it would move the tailwind
* floor without a line of tailwind leaving the site.
*/
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
/** The sheets this repo authors by hand: none. app/layout.tsx imports the
* packages' sheets and a page styles itself with gui props, so the only names
* this repo can declare are the ones a page ships in its own `<style>` (`sheet`). */
export const SHEETS = []
/**
* A name is a SUBJECT only if this repo owns it.
*
* These sheets MENTION names they do not own — a compound selector decorating a
* class some package stamps (`.hz-row:hover`, `.is_Text` in the no-JS pass).
* Reading every mention made those gate subjects, and the gate then asked who
* WEARS them. Nothing here does; the only wearer of `is_Text` and
* `t_unmounted` in the whole tree is `@hanzo/ui/dist/gallery.html`, a demo page
* inside a dependency. So the gate was green because a package happened to ship
* a demo, and a routine bump that stopped shipping it would have made it report
* two live rules — the no-JS and pre-hydration pass — as orphans, under a
* message that says to delete them.
*
* A gate whose verdict depends on a dependency's demo file is not measuring
* this repo. Package-stamped prefixes are excluded: `hz-` is @hanzo/ui's and
* @hanzo/composer's, `is_`/`t_`/`_`/`font_` are gui's runtime classes.
*/
const FOREIGN = /^(hz-|is_|t_|_|font_)/
/**
* The classes one stylesheet DEFINES, whatever file that sheet arrived in.
*
* Split out from `names()` because a sheet this repo authors is a sheet this
* repo authors whether it sits in `app/` as a `.css` file or in a page as a
* template literal it hands to a `<style>` tag. Both are this document
* declaring a name, and there is one reader for both — the alternative was a
* second copy of this regex with a prefix hardcoded in it, which is how the two
* gates came to disagree about "ours" the last time.
*/
export function declared(css) {
const out = new Set()
for (const m of css.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(SELECTOR)) {
for (const name of m[1].split('.')) {
if (name && !FOREIGN.test(name)) out.add(name)
}
}
return out
}
/**
* A class in selector position, INCLUDING the second one in a compound.
*
* `.a.b` and `td.c` declare `b` and `c` as surely as `.a` declares `a`, and a
* reader that wants whitespace before every dot sees only the first of each. It
* cost three real names on the one page that writes its own sheet — `ts-head`,
* `ts-neg` and `ts-r` are only ever `.ts-tier.ts-head`, `.ts-p.ts-neg` and
* `td.ts-r` — which would have left that page counted as three uses of Tailwind
* it does not have.
*
* The optional type before the chain is what reaches `td.ts-r`, and it is the
* reason this stays anchored rather than matching a bare `.name` anywhere: an
* unanchored dot reads `docs.hanzo.ai` inside an `[href^="…"]` as two classes.
*/
const SELECTOR = /(?:^|[\s,>+~({])(?:[a-zA-Z][a-zA-Z0-9-]*)?((?:\.[a-zA-Z][a-zA-Z0-9_-]*)+)/g
export function names() {
const out = new Set()
for (const sheet of SHEETS) {
for (const name of declared(readFileSync(join(ROOT, sheet), 'utf8'))) out.add(name)
}
return out
}
/**
* The stylesheet a page ships inside itself, if it ships one.
*
* `<style dangerouslySetInnerHTML={{ __html: CSS }} />` names the const, and the
* const holds the sheet — so the two are read in that order rather than by
* guessing which template literal looks like CSS. One page does this
* (`/calculator`, whose whole token system is scoped under `.ts`) and the shape
* generalises: any page that declares its own names declares them here.
*/
export function sheet(src) {
// TWO WAYS A PAGE SHIPS ITS OWN RULES, and both are its own. The named-const
// form is read first; the literal form — `<style>{\`…\`}</style>` — is what
// AppBuilderHero writes, and missing it made a class the file declares itself
// read as a utility nobody had a rule for.
const inline = src.match(/<style[^>]*>\{`([\s\S]*?)`\}<\/style>/)
const tag = src.match(/<style[^>]*__html:\s*([A-Za-z_$][\w$]*)/)
if (!tag) return inline ? inline[1] : ''
const decl = src.match(new RegExp(String.raw`\b(?:const|let|var)\s+${tag[1]}\s*=\s*` + '`([\\s\\S]*?)`'))
return (decl ? decl[1] : '') + (inline ? '\n' + inline[1] : '')
}
/**
* The class tokens a file WEARS.
*
* Class position, not free text, and that distinction is the whole point of
* reading it this way. While the vocabulary was prefixed, `hz-run` could be
* searched for anywhere in a file and mean only one thing. `run`, `more`,
* `title`, `field` and `action` are ordinary English and ordinary identifiers,
* so a gate that greps for them raw reports every rule worn, always, and is
* green because it cannot fail rather than because the site is clean.
*
* Both spellings are matched on purpose: `className="…"` is the JSX a component
* is written in, and `className:"…"` is what that JSX compiles to — which is the
* only form the names @hanzo/ui stamps on its own components (`hz-mono`,
* `hz-tnum`, `hz-row`) ever appear in. Reading just the first would report the
* package's interface to its host as dead and invite a delete that breaks it.
*/
const WEARS = /class(?:Name)?\s*[=:]\s*/g
const QUOTE = new Set(['"', "'", '`'])
/** Past a string literal that starts at `i`, to the index after its close. */
function endOfString(text, i) {
const q = text[i]
for (let j = i + 1; j < text.length; j++) {
if (text[j] === '\\') j++
else if (text[j] === q) return j
}
return -1
}
/** The matching `}` for the `{` at `i`, counting depth and stepping over strings. */
function endOfBrace(text, i) {
let depth = 0
for (let j = i; j < text.length; j++) {
const c = text[j]
if (QUOTE.has(c)) {
const e = endOfString(text, j)
if (e < 0) return -1
j = e
} else if (c === '{') depth++
else if (c === '}' && --depth === 0) return j
}
return -1
}
function* split(value) {
// A template hole is decided at run time; the literal around it is not a token
// and reading it would report a name that depends on how the expression was
// written.
for (const raw of value.replace(/\$\{[^}]*\}/g, ' ').split(/\s+/)) {
const t = raw.trim()
if (t) yield t
}
}
/**
* Every class token worn, in order, duplicates included.
*
* A braced value is READ THROUGH rather than skipped, and that is what makes the
* answer trustworthy. `className={c.wide ? 'rate-wide' : undefined}` is how a
* rule that only exists at one breakpoint gets applied, and a reader that wants
* a quote immediately after the `=` sees nothing there — so the rule looks dead,
* and the next person to run this gate deletes a live one. Every string literal
* inside the braces counts, at any depth, which also covers `clsx(...)`, an
* array, and a nested ternary.
*/
export function* tokens(text) {
WEARS.lastIndex = 0
let m
while ((m = WEARS.exec(text))) {
const i = m.index + m[0].length
const c = text[i]
if (QUOTE.has(c)) {
const end = endOfString(text, i)
if (end < 0) continue
yield* split(text.slice(i + 1, end))
WEARS.lastIndex = end + 1
} else if (c === '{') {
const end = endOfBrace(text, i)
if (end < 0) continue
const expr = text.slice(i + 1, end)
for (let j = 0; j < expr.length; j++) {
if (!QUOTE.has(expr[j])) continue
const e = endOfString(expr, j)
if (e < 0) break
yield* split(expr.slice(j + 1, e))
j = e
}
WEARS.lastIndex = end + 1
}
}
}
/** The distinct classes worn — this is a question of presence, not of how many. */
export const classes = (text) => new Set(tokens(text))