From d2c127122bcb21189b027f0759d9e5589c9d9860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kuna?= <1282324+codecalm@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:48:43 +0200 Subject: [PATCH] Add rendered-html gates, tidy the build and CI, clean up docs and preview (#2936) --- .agents/skills/core-scss/SKILL.md | 8 +- .build/build-css.ts | 10 +- .build/check-docs-links.ts | 6 +- .build/check-html.ts | 225 ++++++++++++++++++ .build/check-markup-classes.ts | 111 +++++++++ .build/html-diff.ts | 5 +- .build/markup-classes-baseline.txt | 55 +++++ .build/reformat-mdx.mts | 10 +- .build/tsconfig.json | 17 ++ .build/vite.config.helper.ts | 1 - .changeset/core-type-declarations.md | 5 + .changeset/docs-blur-gray-language.md | 5 + .changeset/docs-example-ids.md | 5 + .changeset/docs-inter-font.md | 5 + .../gradient-stops-transparent-inverted.md | 5 + .changeset/preview-dead-classes-cleanup.md | 5 + .changeset/theme-script-server-attribute.md | 5 + .github/actions/setup/action.yml | 26 ++ .github/dependabot.yml | 4 +- .github/workflows/build.yml | 35 ++- .github/workflows/bundlewatch.yml | 27 +-- .github/workflows/js-tests.yml | 13 +- .github/workflows/lint.yml | 16 +- .github/workflows/release.yml | 24 +- .github/workflows/scss-tests.yml | 13 +- .github/workflows/type-check.yml | 24 +- .gitignore | 1 - core/.build/copy-libs.ts | 14 +- core/.build/import-fonts.ts | 11 +- core/js/tabler-theme.ts | 4 +- core/package.json | 19 +- core/scss/utils/_colors.scss | 16 ++ core/tsconfig.types.json | 11 + docs/astro.config.mjs | 4 +- docs/components/DocsToc.astro | 3 +- docs/content/icons/static-files/svg.mdx | 4 +- docs/content/ui/base/colors.mdx | 11 + docs/content/ui/base/prose.mdx | 8 +- docs/content/ui/base/typography.mdx | 27 +-- docs/content/ui/components/accordion.mdx | 4 +- docs/content/ui/components/alert.mdx | 11 +- docs/content/ui/components/avatar.mdx | 4 +- docs/content/ui/components/badge.mdx | 22 +- docs/content/ui/components/button.mdx | 133 +++-------- docs/content/ui/components/card.mdx | 16 +- docs/content/ui/components/chat.mdx | 10 +- docs/content/ui/components/datagrid.mdx | 4 +- docs/content/ui/components/divider.mdx | 20 +- docs/content/ui/components/dropdown.mdx | 14 +- docs/content/ui/components/empty.mdx | 18 +- docs/content/ui/components/list-group.mdx | 16 +- docs/content/ui/components/mention.mdx | 20 +- docs/content/ui/components/modal.mdx | 24 +- docs/content/ui/components/offcanvas.mdx | 8 +- docs/content/ui/components/progress.mdx | 22 +- .../ui/components/segmented-control.mdx | 76 +++--- docs/content/ui/components/spinner.mdx | 22 +- docs/content/ui/components/status.mdx | 144 +++-------- docs/content/ui/components/tab.mdx | 34 ++- docs/content/ui/components/table.mdx | 14 +- docs/content/ui/components/tooltip.mdx | 10 +- docs/content/ui/forms/select-group.mdx | 12 +- .../ui/getting-started/color-modes.mdx | 2 +- docs/content/ui/getting-started/llms.mdx | 7 +- docs/content/ui/getting-started/upgrade.mdx | 8 +- docs/content/ui/layout/navbars.mdx | 72 ++++-- docs/content/ui/layout/page-headers.mdx | 21 +- docs/content/ui/layout/page-layouts.mdx | 26 +- docs/content/ui/plugins/advanced-select.mdx | 14 +- docs/content/ui/plugins/autosize.mdx | 6 +- docs/content/ui/plugins/chart.mdx | 21 +- docs/content/ui/utilities/background-blur.mdx | 39 +++ docs/content/ui/utilities/borders.mdx | 66 ++--- docs/content/ui/utilities/gradients.mdx | 2 +- docs/content/ui/utilities/interactions.mdx | 8 +- docs/layouts/DocsLayout.astro | 22 +- docs/package.json | 4 +- docs/scss/docs.scss | 8 +- package.json | 19 +- pnpm-lock.yaml | 49 +--- preview/.build/import-icons.mjs | 4 +- preview/package.json | 5 +- preview/pages/2-step-verification-code.astro | 2 +- preview/pages/badges.astro | 2 +- preview/pages/chat.astro | 4 +- preview/pages/email-inbox.astro | 6 +- preview/pages/marketing/real-estate.astro | 6 +- preview/pages/navigation.astro | 2 +- preview/pages/onboarding.astro | 2 +- preview/pages/pay.astro | 4 +- preview/pages/playground-categories.astro | 20 +- preview/pages/sign-in-link.astro | 2 +- shared/components/CaptureScript.astro | 2 +- shared/components/PageScripts.astro | 4 +- shared/components/cards/ActivityCard.astro | 2 +- .../cards/AuthenticateAccount.astro | 2 +- shared/components/cards/HappyBirthday.astro | 2 +- shared/components/cards/Invoices.astro | 2 +- shared/components/cards/PricingPlan.astro | 2 +- shared/components/cards/RecentActivity.astro | 2 +- shared/components/cards/SignInCard.astro | 2 +- shared/components/cards/UserCardBg.astro | 2 +- shared/components/cards/YouWin.astro | 2 +- shared/components/demo/Chat.astro | 4 +- shared/components/forms/FormElements6.astro | 4 +- .../components/marketing/hero/Browser.astro | 2 +- shared/components/marketing/hero/Side.astro | 2 +- .../marketing/sections/PricingBanner.astro | 4 +- .../modals/ChangePasswordModalContent.astro | 2 +- .../modals/SignatureModalContent.astro | 2 +- shared/components/navbar/Navbar.astro | 2 +- shared/components/navbar/NavbarMenu.astro | 2 +- shared/components/navbar/NavbarMenuItem.astro | 2 +- shared/components/navbar/NavbarSideApps.astro | 2 +- shared/data/docs.json | 4 + shared/data/menu.json | 9 + shared/layouts/ErrorLayout.astro | 2 +- shared/layouts/SingleLayout.astro | 2 +- shared/lib/page-scripts.test.ts | 29 ++- shared/lib/page-scripts.ts | 18 +- shared/ui/Avatar.astro | 9 +- shared/ui/Datepicker.astro | 5 +- shared/ui/Empty.astro | 8 +- shared/ui/Illustration.astro | 2 +- shared/ui/Tag.astro | 2 +- turbo.json | 8 +- 126 files changed, 1183 insertions(+), 876 deletions(-) create mode 100644 .build/check-html.ts create mode 100644 .build/check-markup-classes.ts create mode 100644 .build/markup-classes-baseline.txt create mode 100644 .build/tsconfig.json create mode 100644 .changeset/core-type-declarations.md create mode 100644 .changeset/docs-blur-gray-language.md create mode 100644 .changeset/docs-example-ids.md create mode 100644 .changeset/docs-inter-font.md create mode 100644 .changeset/gradient-stops-transparent-inverted.md create mode 100644 .changeset/preview-dead-classes-cleanup.md create mode 100644 .changeset/theme-script-server-attribute.md create mode 100644 .github/actions/setup/action.yml create mode 100644 core/tsconfig.types.json create mode 100644 docs/content/ui/utilities/background-blur.mdx diff --git a/.agents/skills/core-scss/SKILL.md b/.agents/skills/core-scss/SKILL.md index 9e660875e..46391e913 100644 --- a/.agents/skills/core-scss/SKILL.md +++ b/.agents/skills/core-scss/SKILL.md @@ -98,15 +98,15 @@ Add a test when you write a mixin whose output is easy to break silently — the ## 8. Gates ```bash -pnpm run lint:scss # stylelint (twbs config; prettier owns formatting) -pnpm --filter @tabler/core css-lint # + find-unused-sass-variables +pnpm --filter @tabler/core lint:scss # stylelint (twbs config; prettier owns formatting) +pnpm run lint-scss # + find-unused-sass-variables (`lint:scss:vars` in core) pnpm run generate-tokens:check # shared/lib/tokens.ts must match the Sass maps pnpm run lint-prettier # formatting pnpm run bundlewatch # size budgets (tabler.css 80 kB, tabler.min.css 75 kB) ``` - A new entry in `$theme-colors`, `$avatar-sizes` and friends must be regenerated into `shared/lib/tokens.ts` with `pnpm run generate-tokens` — the check gate fails otherwise. -- An unused Sass variable fails `css-lint`; delete it or use it. +- An unused Sass variable fails `lint-scss`; delete it or use it. - Growth past a bundlewatch limit is a decision, not an accident: raise the number in `core/package.json` deliberately and say so in the PR. ## 9. Checklist @@ -117,5 +117,5 @@ pnpm run bundlewatch # size budgets (tabler.css 80 kB, tab - [ ] Dark mode via `light-dark()` or all three dark selectors, behind `$enable-dark-mode` - [ ] Logical properties, or `--dir` + `/* rtl:ignore */`; no hand-written RTL - [ ] sass-true test for a mixin that can break silently -- [ ] `lint:scss`, `css-lint`, `generate-tokens:check`, `lint-prettier`, `test:scss` clean +- [ ] `lint-scss`, `generate-tokens:check`, `lint-prettier`, `test:scss` clean - [ ] Docs page and class table updated (`write-docs`, `class-reference`), changeset written diff --git a/.build/build-css.ts b/.build/build-css.ts index 5b3f89a4f..512ff308b 100644 --- a/.build/build-css.ts +++ b/.build/build-css.ts @@ -13,7 +13,10 @@ // --banner adds the license comment before any source map is generated, so // the maps account for its lines (#2766). // -// Usage: tsx ../.build/build-css.ts [--rtl] [--minify] [--banner] +// --no-prefix skips the --tblr- pass, for a stylesheet that inlines vendor css +// whose variables are set at runtime (docs/scss/docs.scss). +// +// Usage: tsx ../.build/build-css.ts [--rtl] [--minify] [--banner] [--no-prefix] // (cwd = the package) /// import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' @@ -32,12 +35,13 @@ const args = process.argv.slice(2) const flags = args.filter((arg) => arg.startsWith('--')) const [scssDir, outDir] = args.filter((arg) => !arg.startsWith('--')) if (!scssDir || !outDir) { - console.error('usage: tsx build-css.ts [--rtl] [--minify] [--banner]') + console.error('usage: tsx build-css.ts [--rtl] [--minify] [--banner] [--no-prefix]') process.exit(1) } const withRtl = flags.includes('--rtl') const withMinify = flags.includes('--minify') const withBanner = flags.includes('--banner') +const withPrefix = !flags.includes('--no-prefix') const mapOptions = { inline: false, annotation: true, sourcesContent: true } @@ -66,7 +70,7 @@ async function compile(entry: string): Promise<{ outFile: string; result: Result // postcss maps generated positions back to *its own input*, so renaming // inside the map-generating pass would leave every mapping — and the // embedded sourcesContent — describing css that no longer exists on disk. - const { css: prefixed } = await postcss([inlineValueComments, prefixCustomProperties({ prefix: cssVarPrefix, ignore: cssVarIgnore })]).process(input, { from: outFile, to: outFile, map: false }) + const { css: prefixed } = withPrefix ? await postcss([inlineValueComments, prefixCustomProperties({ prefix: cssVarPrefix, ignore: cssVarIgnore })]).process(input, { from: outFile, to: outFile, map: false }) : { css: input } const result = await postcss([autoprefixer({ cascade: false })]).process(prefixed, { from: outFile, to: outFile, diff --git a/.build/check-docs-links.ts b/.build/check-docs-links.ts index c69c973c0..5504ca395 100644 --- a/.build/check-docs-links.ts +++ b/.build/check-docs-links.ts @@ -88,7 +88,7 @@ const headingSlugsOf = (file: string): Set => { const slugger = new GithubSlugger() slugs = new Set() if (file.endsWith('.mdx')) { - for (const line of stripDemosAndCode(body(file)).split('\n')) { + for (const line of stripFences(body(file)).split('\n')) { const heading = line.match(/^#{1,6}\s+(.*)$/) if (heading) { const text = (heading[1] ?? '').replace(/[`*_]|\[([^\]]*)\]\([^)]*\)/g, '$1').trim() @@ -111,7 +111,9 @@ const frontmatter = (file: string): string => { return source.startsWith('---') ? (source.split(/^---$/m, 3)[1] ?? '') : '' } -const stripCode = (text: string): string => text.replace(/^```[\s\S]*?^```/gm, '').replace(/`[^`\n]*`/g, '') +// Fenced blocks only: heading text keeps its inline code, which the slugger sees too. +const stripFences = (text: string): string => text.replace(/^```[\s\S]*?^```/gm, '') +const stripCode = (text: string): string => stripFences(text).replace(/`[^`\n]*`/g, '') const stripDemosAndCode = (text: string): string => stripCode(text) diff --git a/.build/check-html.ts b/.build/check-html.ts new file mode 100644 index 000000000..0568c77e0 --- /dev/null +++ b/.build/check-html.ts @@ -0,0 +1,225 @@ +// Renders every page of a built site (or a running dev server) in Chromium and +// fails on the mistakes a static check cannot see: dead internal links, script +// errors, duplicate ids, controls that point at ids that do not exist, nested +// interactive elements. Images without alt and controls without an accessible +// name are reported as warnings. +// +// Pages come from the site's sitemap.xml. A directory target (preview/dist, +// docs/dist) is served locally; an http(s) target is crawled as is. +// +// Run: pnpm run check-html preview/dist docs/dist +// pnpm run check-html http://localhost:3000 +import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs' +import { createServer, type Server } from 'node:http' +import { extname, join, resolve } from 'node:path' +import { chromium, type Browser, type Page } from 'playwright' + +const MIME: Record = { + '.html': 'text/html', + '.css': 'text/css', + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.json': 'application/json', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.gif': 'image/gif', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', + '.woff': 'font/woff', + '.ttf': 'font/ttf', + '.xml': 'application/xml', + '.txt': 'text/plain', + '.md': 'text/markdown', + '.map': 'application/json', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mp3': 'audio/mpeg', +} + +// Ids generated by libraries at runtime; duplicates there are theirs, not ours. +const VENDOR_ID_PREFIXES = ['apexcharts-', 'jvm-', 'SvgjsSvg', 'SvgjsLinearGradient'] +// Console noise from third-party demos (WebGL driver messages, Google Maps without a key, plyr). +const CONSOLE_IGNORE = [/GL Driver Message/, /Google Maps JavaScript API/, /allowfullscreen/, /web-share/, /No available adapters/, /\[vite\]/, /astro/i] + +type PageReport = { url: string; errors: string[]; warnings: string[] } + +const resolveFile = (root: string, urlPath: string): string | null => { + const clean = decodeURIComponent(urlPath.split('?')[0]!.split('#')[0]!) + // `/x/` and `/x/index.html` also resolve to x.html: Astro's file format + // collapses x/index.astro to x.html and the hosting serves it under both. + const collapsed = clean.replace(/\/(index\.html)?$/, '') + const candidates = [join(root, clean), join(root, `${clean}.html`), join(root, clean, 'index.html'), join(root, `${collapsed}.html`)] + for (const c of candidates) { + if (existsSync(c) && statSync(c).isFile()) return c + } + return null +} + +const serve = (root: string): Promise<{ server: Server; base: string }> => + new Promise((done) => { + const server = createServer((req, res) => { + const file = resolveFile(root, req.url ?? '/') + if (!file) { + res.writeHead(404).end('not found') + return + } + res.writeHead(200, { 'content-type': MIME[extname(file)] ?? 'application/octet-stream' }) + if (req.method === 'HEAD') { + res.end() + return + } + createReadStream(file).pipe(res) + }) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + const port = typeof address === 'object' && address ? address.port : 0 + done({ server, base: `http://127.0.0.1:${port}` }) + }) + }) + +const sitemapPaths = async (base: string, dir?: string): Promise => { + const xml = dir ? readFileSync(join(dir, 'sitemap.xml'), 'utf8') : await (await fetch(`${base}/sitemap.xml`)).text() + return [...xml.matchAll(/([^<]+)<\/loc>/g)].map((m) => m[1]!.replace(/^https?:\/\/[^/]+/, '')).filter((p) => p.startsWith('/')) +} + +// Runs in the page as plain JavaScript (a string, so no bundler helpers such as +// esbuild's __name leak into the browser): everything the DOM can tell us. +const INSPECT = `(() => { + const accName = (el) => { + const labelledBy = el.getAttribute('aria-labelledby') + if (labelledBy) { + const text = labelledBy.split(/\\s+/).map((id) => (document.getElementById(id) || {}).textContent || '').join(' ').trim() + if (text) return text + } + const own = el.getAttribute('aria-label') || el.getAttribute('title') || (el.innerText || '').trim() || (el.textContent || '').trim() + if (own) return own + return [...el.querySelectorAll('img[alt], [role="img"][aria-label], [aria-label], svg title')].map((e) => e.getAttribute('alt') || e.getAttribute('aria-label') || e.textContent || '').join(' ').trim() + } + const hidden = (el) => !!el.closest('[aria-hidden="true"]') + const ids = new Map() + for (const el of document.querySelectorAll('[id]')) ids.set(el.id, (ids.get(el.id) || 0) + 1) + const duplicateIds = [...ids].filter(([, n]) => n > 1).map(([id]) => id) + const missingTargets = [] + const check = (attr, value) => { + if (!value) return + for (const raw of value.split(/\\s+/)) { + const id = raw.replace(/^#/, '') + if (!id) continue + if (!document.getElementById(id)) missingTargets.push(attr + '="' + raw + '"') + } + } + for (const el of document.querySelectorAll('[data-bs-target^="#"]')) check('data-bs-target', el.getAttribute('data-bs-target')) + for (const el of document.querySelectorAll('[data-bs-toggle][href^="#"]')) check('href', el.getAttribute('href')) + for (const el of document.querySelectorAll('[aria-controls]')) check('aria-controls', el.getAttribute('aria-controls')) + for (const el of document.querySelectorAll('[aria-labelledby]')) check('aria-labelledby', el.getAttribute('aria-labelledby')) + for (const el of document.querySelectorAll('[aria-describedby]')) check('aria-describedby', el.getAttribute('aria-describedby')) + for (const el of document.querySelectorAll('label[for]')) check('for', el.getAttribute('for')) + const short = (e) => e.outerHTML.slice(0, 80).replace(/\\s+/g, ' ') + const nested = [...document.querySelectorAll('a a, a button, button a, button button, label label, a [role="button"], button [role="button"]')].map(short) + const imgNoAlt = [...document.querySelectorAll('img:not([alt])')].filter((e) => !hidden(e)).map((e) => e.getAttribute('src') || '') + const unnamed = [...document.querySelectorAll('a[href], button')].filter((e) => !hidden(e) && !accName(e)).map(short) + const links = [...document.querySelectorAll('a[href]')].map((a) => a.getAttribute('href')).filter((h) => !h.startsWith('#') && !/^(https?:|mailto:|tel:|javascript:|data:)/.test(h)) + return { duplicateIds: [...new Set(duplicateIds)], missingTargets: [...new Set(missingTargets)], nested, imgNoAlt, unnamed, links } +})()` + +type Inspection = { duplicateIds: string[]; missingTargets: string[]; nested: string[]; imgNoAlt: string[]; unnamed: string[]; links: string[] } + +const crawl = async (browser: Browser, base: string, paths: string[]) => { + const reports: PageReport[] = [] + const links = new Map() + const queue = [...paths] + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }) + const worker = async () => { + while (queue.length) { + const path = queue.shift()! + const page: Page = await context.newPage() + const report: PageReport = { url: path, errors: [], warnings: [] } + page.on('pageerror', (e) => report.errors.push(`script error: ${String(e).slice(0, 160)}`)) + page.on('console', (m) => { + if (m.type() !== 'error') return + const text = m.text() + if (!CONSOLE_IGNORE.some((re) => re.test(text))) report.warnings.push(`console: ${text.slice(0, 160)}`) + }) + page.on('response', (r) => { + // /_vercel/ is Vercel's own analytics script, which only exists once deployed there. + if (r.status() >= 400 && r.url().startsWith(base) && !r.url().includes('/_vercel/')) report.errors.push(`${r.status()} ${r.url().slice(base.length)}`) + }) + try { + await page.goto(base + path, { waitUntil: 'load', timeout: 60_000 }) + await page.waitForTimeout(500) + const r = (await page.evaluate(INSPECT)) as Inspection + for (const id of r.duplicateIds) if (!VENDOR_ID_PREFIXES.some((p) => id.startsWith(p))) report.errors.push(`duplicate id "${id}"`) + for (const t of r.missingTargets) report.errors.push(`no element for ${t}`) + for (const n of r.nested) report.errors.push(`nested interactive: ${n}`) + for (const src of r.imgNoAlt) report.warnings.push(`img without alt: ${src}`) + for (const html of r.unnamed) report.warnings.push(`no accessible name: ${html}`) + for (const href of r.links) { + const key = href.split(/[?#]/)[0]! + if (!links.has(key)) links.set(key, path) + } + } catch (e) { + report.errors.push(`navigation: ${String(e).slice(0, 160)}`) + } + reports.push(report) + await page.close() + } + } + await Promise.all(Array.from({ length: 4 }, worker)) + await context.close() + for (const [href, from] of links) { + const url = href.startsWith('/') ? base + href : new URL(href, base + from).href + try { + let res = await fetch(url, { method: 'HEAD', redirect: 'manual' }) + if (res.status === 405 || res.status === 501) res = await fetch(url, { method: 'GET', redirect: 'manual' }) + if (res.status >= 400) reports.push({ url: from, errors: [`dead link ${href} (${res.status})`], warnings: [] }) + } catch (e) { + reports.push({ url: from, errors: [`dead link ${href} (${String(e).slice(0, 60)})`], warnings: [] }) + } + } + return reports +} + +const main = async () => { + const targets = process.argv.slice(2).filter((a) => !a.startsWith('--')) + const quiet = process.argv.includes('--quiet') + if (targets.length === 0) { + console.error('Usage: tsx .build/check-html.ts ...') + process.exit(2) + } + const browser = await chromium.launch() + let errorCount = 0 + let warningCount = 0 + for (const target of targets) { + let base = target + let server: Server | undefined + let dir: string | undefined + if (!/^https?:/.test(target)) { + dir = resolve(target) + ;({ server, base } = await serve(dir)) + } + const paths = await sitemapPaths(base, dir) + const reports = await crawl(browser, base, paths) + const errors = reports.filter((r) => r.errors.length) + const warnings = reports.filter((r) => r.warnings.length) + errorCount += errors.reduce((n, r) => n + r.errors.length, 0) + warningCount += warnings.reduce((n, r) => n + r.warnings.length, 0) + console.log(`${target}: ${paths.length} pages, ${errors.reduce((n, r) => n + r.errors.length, 0)} errors, ${warnings.reduce((n, r) => n + r.warnings.length, 0)} warnings`) + for (const r of errors) for (const e of r.errors) console.log(` ✗ ${r.url}: ${e}`) + if (!quiet) for (const r of warnings) for (const w of r.warnings) console.log(` · ${r.url}: ${w}`) + server?.close() + } + await browser.close() + if (errorCount > 0) { + console.error(`\n${errorCount} error(s), ${warningCount} warning(s)`) + process.exit(1) + } + console.log(`\nOK — no errors, ${warningCount} warning(s)`) +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/.build/check-markup-classes.ts b/.build/check-markup-classes.ts new file mode 100644 index 000000000..21276bd92 --- /dev/null +++ b/.build/check-markup-classes.ts @@ -0,0 +1,111 @@ +// Fails when a rendered page uses a class that no shipped stylesheet defines. +// +// A typo in a class name (`ml-auto`, `progress-xs`) renders as nothing: no +// error, no warning, just markup that silently does not do what it says — and +// it ends up in the html people copy from the preview. This scans the class +// attributes of every page from a site's sitemap and looks each class up in +// the css of the given directories. +// +// Classes that exist only as JavaScript or demo hooks are listed in +// markup-classes-baseline.txt. Anything not on that list fails the check; so +// does a name on the list that no longer appears, which keeps the file from +// going stale — delete the line when you drop the hook. +// +// Run: pnpm run check-markup-classes --html preview/dist --css core/dist/css --css core/dist/libs +// Targets can be a built directory or a running server (http://localhost:3000). +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + +const BASELINE = resolve(__dirname, 'markup-classes-baseline.txt') + +const args = process.argv.slice(2) +const htmlTargets: string[] = [] +const cssDirs: string[] = [] +let updateBaseline = false +for (let i = 0; i < args.length; i++) { + if (args[i] === '--html') htmlTargets.push(args[++i]!) + else if (args[i] === '--css') cssDirs.push(args[++i]!) + else if (args[i] === '--update-baseline') updateBaseline = true +} +if (htmlTargets.length === 0 || cssDirs.length === 0) { + console.error('Usage: tsx .build/check-markup-classes.ts --html [--html …] --css [--css …] [--update-baseline]') + process.exit(2) +} + +const walk = (dir: string, ext: string): string[] => + readdirSync(dir).flatMap((name) => { + const path = join(dir, name) + return statSync(path).isDirectory() ? walk(path, ext) : path.endsWith(ext) ? [path] : [] + }) + +const defined = new Set() +for (const dir of cssDirs) { + for (const file of walk(resolve(dir), '.css')) { + for (const m of readFileSync(file, 'utf8').matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)) defined.add(m[1]!) + } +} + +const pagesOf = async (target: string): Promise<{ path: string; html: string }[]> => { + if (/^https?:/.test(target)) { + const xml = await (await fetch(`${target}/sitemap.xml`)).text() + const paths = [...xml.matchAll(/([^<]+)<\/loc>/g)].map((m) => m[1]!.replace(/^https?:\/\/[^/]+/, '')) + const pages = [] + for (const path of paths) pages.push({ path, html: await (await fetch(target + path)).text() }) + return pages + } + const root = resolve(target) + const xml = readFileSync(join(root, 'sitemap.xml'), 'utf8') + return [...xml.matchAll(/([^<]+)<\/loc>/g)].map((m) => { + const path = m[1]!.replace(/^https?:\/\/[^/]+/, '') + const file = [join(root, path), join(root, `${path}.html`), join(root, path, 'index.html')].find((f) => existsSync(f) && statSync(f).isFile()) + return { path, html: file ? readFileSync(file, 'utf8') : '' } + }) +} + +const main = async () => { + const used = new Map>() + let pageCount = 0 + for (const target of htmlTargets) { + for (const { path, html } of await pagesOf(target)) { + pageCount++ + // Page-level