mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-28 07:32:52 +00:00
207 lines
8.6 KiB
JavaScript
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))
|