From bcf5991167d915a32f5173be708a408769b55217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kuna?= <1282324+codecalm@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:18:58 +0200 Subject: [PATCH] Add SRI hashes to the CDN snippets in the docs (#3086) --- .agents/skills/core-scss/SKILL.md | 2 +- .build/generate-sri.ts | 171 ++++++++++++++++++ .build/generate-tokens.ts | 8 +- .build/import-payments.ts | 4 +- .build/release-main.ts | 87 +++++++++ .changeset/docs-cdn-sri.md | 5 + .github/release-notes/README.md | 19 ++ .gitignore | 1 - CONTRIBUTING.md | 4 +- .../ui/getting-started/color-modes.mdx | 4 +- .../ui/getting-started/installation.mdx | 2 + docs/lib/cdn-snippets.ts | 28 ++- docs/lib/llms.ts | 3 +- package.json | 5 +- shared/data/sri.json | 22 +++ shared/lib/tokens.ts | 2 +- 16 files changed, 349 insertions(+), 18 deletions(-) create mode 100644 .build/generate-sri.ts create mode 100644 .build/release-main.ts create mode 100644 .changeset/docs-cdn-sri.md create mode 100644 shared/data/sri.json diff --git a/.agents/skills/core-scss/SKILL.md b/.agents/skills/core-scss/SKILL.md index 45ac9741a..521aa5682 100644 --- a/.agents/skills/core-scss/SKILL.md +++ b/.agents/skills/core-scss/SKILL.md @@ -107,7 +107,7 @@ 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. +- 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 `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. diff --git a/.build/generate-sri.ts b/.build/generate-sri.ts new file mode 100644 index 000000000..cbe93c4c2 --- /dev/null +++ b/.build/generate-sri.ts @@ -0,0 +1,171 @@ +#!/usr/bin/env node +// Regenerates shared/data/sri.json, the Subresource Integrity hashes the docs put into the CDN +// snippets. The hashes are read from the files jsDelivr already serves for the published +// @tabler/core version, never from a local build: a local dist is not the file the browser +// downloads, so its hash would make the browser block the stylesheet or the script. +// +// Run it after a release, before merging `dev` into `main` (docs.tabler.io is built from `main`): +// +// pnpm run generate:sri (once the new version is on npm) +// pnpm run generate:sri --wait (same, but waits for the release to reach npm and the CDN) +// pnpm run check:sri (the committed hashes still match the CDN) +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { site } from '../shared/lib/site' + +interface SriData { + version: string + algorithm: string + files: Record +} + +const algorithm = 'sha384' +const waitMinutes = 10 + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const repoRoot = join(__dirname, '..') +const dataFile = join(repoRoot, 'shared/data/sri.json') + +// The files the docs hand out as CDN tags. The plugin bundles in `dist/libs/` are not here: they +// are third-party packages the docs only link to, and each one has its own version. +const files = ['dist/css/tabler.min.css', 'dist/css/tabler.rtl.min.css', 'dist/js/tabler.min.js', 'dist/js/tabler-theme.min.js', ...site.cssPlugins.flatMap((plugin) => [`dist/css/tabler-${plugin}.min.css`, `dist/css/tabler-${plugin}.rtl.min.css`])] + +/** One GET, retried once: a single dropped connection should not fail a CI job. */ +async function get(url: string): Promise { + for (let attempt = 1; ; attempt++) { + try { + const response = await fetch(url) + if (response.ok || response.status === 404 || attempt === 2) { + return response + } + console.warn(`generate:sri: ${url} returned ${response.status}, retrying`) + } catch (error: unknown) { + if (attempt === 2) { + throw error + } + console.warn(`generate:sri: ${url} failed (${error instanceof Error ? error.message : String(error)}), retrying`) + } + } +} + +/** Whether the version in core/package.json is on npm, and so on the CDN as well. */ +async function isPublished(): Promise { + return (await get(`https://registry.npmjs.org/@tabler/core/${site.version}`)).ok +} + +/** The integrity value of one published file, downloaded from the CDN the docs point at. */ +async function hashFile(file: string): Promise { + const url = `${site.cdnUrl}/${file}` + const response = await get(url) + + if (!response.ok) { + throw new Error(`generate:sri: ${url} returned ${response.status}`) + } + + const body = Buffer.from(await response.arrayBuffer()) + + return `${algorithm}-${createHash(algorithm).update(body).digest('base64')}` +} + +async function collectHashes(): Promise { + const hashes = await Promise.all(files.map(async (file) => [file, await hashFile(file)] as const)) + + return { version: site.version, algorithm, files: Object.fromEntries(hashes) } +} + +/** + * `pnpm run check:sri` - the committed hashes have to match what the CDN serves today. + * + * Hashes for an older version are a safe state: the docs render the tags without `integrity` + * until `generate:sri` runs. So they only warn. + */ +async function check(): Promise { + if (!existsSync(dataFile)) { + throw new Error('check:sri: shared/data/sri.json is missing - run `pnpm run generate:sri` and commit the result') + } + + const committed: SriData = JSON.parse(readFileSync(dataFile, 'utf8')) + + // The version pull request changesets opens bumps core/package.json before anything is on npm. + // There is nothing to compare against yet, and the hashes get regenerated after the release, so + // this is a pass rather than a failure. + if (!(await isPublished())) { + console.log(`check:sri: @tabler/core v${site.version} is not published yet, nothing to compare`) + return + } + + if (committed.version !== site.version) { + console.warn(`check:sri: shared/data/sri.json has hashes for v${committed.version}, but @tabler/core is v${site.version} - the docs render the CDN tags without \`integrity\` until you run \`pnpm run generate:sri\``) + return + } + + const current = await collectHashes() + const problems: string[] = [] + + for (const file of files) { + if (committed.files[file] !== current.files[file]) { + problems.push(`${file}: ${committed.files[file] ?? 'missing'} committed, ${current.files[file]} on the CDN`) + } + } + + if (problems.length > 0) { + console.error(`check:sri: shared/data/sri.json is out of date, run \`pnpm run generate:sri\`:\n ${problems.join('\n ')}`) + process.exitCode = 1 + return + } + + console.log(`check:sri: ${files.length} hashes match @tabler/core v${current.version}`) +} + +/** + * `--wait`, for running it right after a release: `changeset publish` returns before npm and the + * CDN have the new version everywhere, so poll instead of failing on the first 404. + */ +async function collectWhenPublished(): Promise { + const deadline = Date.now() + waitMinutes * 60_000 + + for (let attempt = 1; ; attempt++) { + try { + if (await isPublished()) { + return await collectHashes() + } + } catch (error: unknown) { + if (Date.now() >= deadline) { + throw error + } + } + + if (Date.now() >= deadline) { + throw new Error(`generate:sri: @tabler/core v${site.version} did not show up on the CDN within ${waitMinutes} minutes`) + } + + console.log(`generate:sri: waiting for @tabler/core v${site.version} on the CDN (attempt ${attempt})`) + await new Promise((resolve) => setTimeout(resolve, 15_000)) + } +} + +async function generate(wait: boolean): Promise { + if (!wait && !(await isPublished())) { + throw new Error(`generate:sri: @tabler/core v${site.version} is not on npm yet - run this after \`changeset publish\``) + } + + const data = wait ? await collectWhenPublished() : await collectHashes() + + writeFileSync(dataFile, `${JSON.stringify(data, null, 2)}\n`) + + console.log(`generate:sri: wrote ${files.length} ${algorithm} hashes for @tabler/core v${data.version}`) +} + +// Wrapped in main() because the root package is CJS (no top-level await). +const main = async () => { + const args = process.argv.slice(2) + + await (args.includes('check') ? check() : generate(args.includes('--wait'))) +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exit(1) +}) diff --git a/.build/generate-tokens.ts b/.build/generate-tokens.ts index 4e0edf6d2..1ca60cbd2 100644 --- a/.build/generate-tokens.ts +++ b/.build/generate-tokens.ts @@ -3,8 +3,8 @@ // unions used by components can never drift from the stylesheet source of // truth. Values are read through the real Sass compiler (after every merge and // !default), not by parsing the source text. -// Run: pnpm run generate-tokens — (re)write the file -// pnpm run check:tokens — fail when the committed file is stale +// Run: pnpm run generate:tokens — (re)write the file +// pnpm run check:tokens — fail when the committed file is stale import { readFileSync, writeFileSync, existsSync } from 'node:fs' import { join, dirname, relative } from 'node:path' import { fileURLToPath } from 'node:url' @@ -64,7 +64,7 @@ compileString(entry, { }) let output = `// Generated from the core SCSS maps by .build/generate-tokens.ts — DO NOT EDIT. -// Regenerate with: pnpm run generate-tokens +// Regenerate with: pnpm run generate:tokens ` for (const token of TOKENS) { const keys = keysByName.get(token.name) @@ -88,7 +88,7 @@ const main = async () => { if (mode === 'check') { const current = existsSync(outFile) ? readFileSync(outFile, 'utf8') : '' if (current !== formatted) { - console.error(`✖ ${relative(repoRoot, outFile)} is stale — run "pnpm run generate-tokens" and commit the result.`) + console.error(`✖ ${relative(repoRoot, outFile)} is stale — run "pnpm run generate:tokens" and commit the result.`) process.exit(1) } console.log(`✓ ${relative(repoRoot, outFile)} is up to date.`) diff --git a/.build/import-payments.ts b/.build/import-payments.ts index a4e92d1d9..339faafe7 100644 --- a/.build/import-payments.ts +++ b/.build/import-payments.ts @@ -3,7 +3,7 @@ // and dark SVGs, the $payment-providers list and the demo data. // // Run: pnpm run import:payments -// Then: pnpm run generate-tokens — refreshes PaymentProvider in shared/lib/tokens.ts +// Then: pnpm run generate:tokens — refreshes PaymentProvider in shared/lib/tokens.ts import { execFileSync } from 'node:child_process' import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -86,7 +86,7 @@ try { console.log(`Imported ${providers.length} payment providers from ${relative(repoRoot, sourceDir)} (${added.length} added, ${removed.length} removed)`) if (added.length) console.log(` added: ${added.join(', ')}`) if (removed.length) console.log(` removed: ${removed.join(', ')}`) - console.log('Now run `pnpm run generate-tokens` to refresh shared/lib/tokens.ts') + console.log('Now run `pnpm run generate:tokens` to refresh shared/lib/tokens.ts') } finally { if (cloneDir) rmSync(cloneDir, { recursive: true, force: true }) } diff --git a/.build/release-main.ts b/.build/release-main.ts new file mode 100644 index 000000000..05e2173d9 --- /dev/null +++ b/.build/release-main.ts @@ -0,0 +1,87 @@ +#!/usr/bin/env node +// Publishes a release to docs.tabler.io, which is built from `main`. Run it on `dev` once the +// release workflow has put the new version on npm: +// +// 1. refreshes shared/data/sri.json for that version (`generate:sri --wait`), +// 2. commits it and pushes `dev`, +// 3. merges `dev` into `main` and pushes `main`. +// +// The merge happens in a temporary worktree, so the current checkout (and a dev server running +// from it) never switches branches. +// +// Run: pnpm run release:main (does all of the above) +// pnpm run release:main --dry-run (refreshes and commits locally, pushes nothing) + +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const repoRoot = join(__dirname, '..') +const sriFile = 'shared/data/sri.json' +const dryRun = process.argv.includes('--dry-run') + +const git = (args: string[], cwd = repoRoot): string => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() +const run = (command: string, args: string[]): void => { + execFileSync(command, args, { cwd: repoRoot, stdio: 'inherit' }) +} +const step = (message: string): void => { + console.log(`\nrelease:main: ${message}`) +} + +const { version } = JSON.parse(readFileSync(join(repoRoot, 'core', 'package.json'), 'utf8')) as { version: string } + +const main = (): void => { + if (git(['branch', '--show-current']) !== 'dev') { + throw new Error('release:main: run this on the `dev` branch') + } + + if (git(['status', '--porcelain', '--untracked-files=no']) !== '') { + throw new Error('release:main: the working tree has uncommitted changes - commit or stash them first') + } + + step('updating dev from origin') + git(['fetch', 'origin', 'dev', 'main']) + git(['merge', '--ff-only', 'origin/dev']) + + step(`refreshing the SRI hashes for @tabler/core v${version}`) + run(join(repoRoot, 'node_modules', '.bin', 'tsx'), [join('.build', 'generate-sri.ts'), '--wait']) + + if (git(['status', '--porcelain', '--', sriFile]) === '') { + console.log(`release:main: ${sriFile} is already up to date`) + } else { + git(['commit', '-m', `Update SRI hashes for v${version}`, '--', sriFile]) + console.log(`release:main: committed ${sriFile}`) + } + + if (dryRun) { + step('--dry-run: stopping before any push') + return + } + + step('pushing dev') + run('git', ['push', 'origin', 'dev']) + + step('merging dev into main') + const worktree = mkdtempSync(join(tmpdir(), 'tabler-main-')) + + try { + git(['worktree', 'add', '--detach', worktree, 'origin/main']) + git(['merge', '--no-edit', '-m', `Merge dev into main for v${version}`, 'dev'], worktree) + execFileSync('git', ['push', 'origin', 'HEAD:main'], { cwd: worktree, stdio: 'inherit' }) + } finally { + git(['worktree', 'remove', '--force', worktree]) + rmSync(worktree, { recursive: true, force: true }) + } + + step(`done - docs.tabler.io deploys v${version} from main`) +} + +try { + main() +} catch (error: unknown) { + console.error(error instanceof Error ? error.message : error) + process.exit(1) +} diff --git a/.changeset/docs-cdn-sri.md b/.changeset/docs-cdn-sri.md new file mode 100644 index 000000000..68e2bb20e --- /dev/null +++ b/.changeset/docs-cdn-sri.md @@ -0,0 +1,5 @@ +--- +"@tabler/docs": patch +--- + +Added `integrity` and `crossorigin` attributes to the CDN snippets on the installation and plugin pages. diff --git a/.github/release-notes/README.md b/.github/release-notes/README.md index cf434da53..e35b5cac6 100644 --- a/.github/release-notes/README.md +++ b/.github/release-notes/README.md @@ -51,6 +51,25 @@ pick: `pnpm run release-notes` prints the body to stdout, so you can read it before the release goes out. Pass a path to write it to a file instead. +## After the release + +docs.tabler.io is built from `main`. Once the release workflow has published +the new version, run this on `dev`: + +```shell +pnpm run release:main +``` + +It refreshes the `integrity` hashes of the CDN snippets in +`shared/data/sri.json` (they can only be read once the version is on npm), +commits them, pushes `dev`, then merges `dev` into `main` and pushes `main`. The +merge runs in a temporary worktree, so your checkout stays on `dev`. Add +`--dry-run` to stop after the local commit. + +Until the hashes are refreshed the docs show the CDN tags without `integrity`. +That is safe, just less strict. `pnpm run check:sri` compares the committed +hashes with the CDN if you want to check them by hand. + ## Fixing a published release Edit the release on GitHub. The workflow only writes the body once, right after diff --git a/.gitignore b/.gitignore index de2a85bba..c48d7525b 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,6 @@ dist/ packages-zip/ .env .env*.local -sri.json # TypeScript *.tsbuildinfo diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 890b078c7..5463dee0c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,12 +63,12 @@ Do not edit `dist/` folders — they are generated by the build. | `pnpm run check` | Lint plus TypeScript type checks | | `pnpm run html-diff:baseline` | Snapshot all rendered preview pages (needs the dev server) | | `pnpm run html-diff` | Byte-compare rendered preview pages against the snapshot | -| `pnpm run generate-tokens` | Regenerate `shared/lib/tokens.ts` from the core SCSS maps | +| `pnpm run generate:tokens` | Regenerate `shared/lib/tokens.ts` from the core SCSS maps | Two of these help with refactors: - **`html-diff`** proves a refactor does not change the rendered demo HTML: run `html-diff:baseline` before your change, refactor, then `html-diff` — it reports the first differing line per page and marks whitespace-only differences. -- **`generate-tokens`** keeps the TS unions in `shared/lib/tokens.ts` in sync with the SCSS maps (colors, sizes, breakpoints, payment providers, flags). Edit the SCSS map, regenerate, commit both — a stale file fails `pnpm run lint`. +- **`generate:tokens`** keeps the TS unions in `shared/lib/tokens.ts` in sync with the SCSS maps (colors, sizes, breakpoints, payment providers, flags). Edit the SCSS map, regenerate, commit both — a stale file fails `pnpm run lint`. ## Before you open a pull request diff --git a/docs/content/ui/getting-started/color-modes.mdx b/docs/content/ui/getting-started/color-modes.mdx index d08fad063..74d55175b 100644 --- a/docs/content/ui/getting-started/color-modes.mdx +++ b/docs/content/ui/getting-started/color-modes.mdx @@ -5,8 +5,8 @@ description: Set up Tabler's light, dark, and auto color modes, and learn the th related: [/ui/getting-started/customize, /ui/plugins/theme-base-colors] --- -import { site } from '@shared/lib/site.ts'; import { Code } from 'astro:components'; +import { cdnThemeJsTag } from '@lib/cdn-snippets.ts'; ## Overview @@ -48,7 +48,7 @@ Load the theme script right after the opening `` tag, and do not add `defe - + ${cdnThemeJsTag()} ... `} /> diff --git a/docs/content/ui/getting-started/installation.mdx b/docs/content/ui/getting-started/installation.mdx index 7a6fe1fa7..8654dbe64 100644 --- a/docs/content/ui/getting-started/installation.mdx +++ b/docs/content/ui/getting-started/installation.mdx @@ -64,6 +64,8 @@ Put the stylesheet in `` and the script at the end of ``. The file t That's the whole setup. The CSS covers the layout and components, and the script adds the interactive parts such as dropdowns and modals. +The `integrity` attribute lets the browser check that the file it downloaded is the one Tabler published, and `crossorigin="anonymous"` is what makes that check possible on a CDN. Each hash belongs to one file of one version, so when you move to a newer Tabler, copy the tags from this page again instead of only changing the version number in the URL. + If you'd rather not depend on a CDN, you can download the files and serve them from your own project. The [Download](/ui/getting-started/download) page explains how. ### Open in your browser diff --git a/docs/lib/cdn-snippets.ts b/docs/lib/cdn-snippets.ts index 32572a196..ba61ea1fa 100644 --- a/docs/lib/cdn-snippets.ts +++ b/docs/lib/cdn-snippets.ts @@ -2,15 +2,37 @@ // re-renders them into the /llms.txt endpoints, and the installation page drops the two tags into // a full HTML example. import { site } from '@shared/lib/site.ts' +import sri from '@data/sri.json' + +/** + * `integrity` and `crossorigin` for a file of the published core package, so the browser can + * verify what the CDN sent. The hashes in shared/data/sri.json come from `pnpm run generate:sri`; + * until that runs for the current version the tags are rendered without them, rather than with a + * hash that would make the browser block the file. + */ +const sriAttrs = (file: string): string => { + const hash = sri.version === site.version ? (sri.files as Record)[file] : undefined + + return hash ? ` integrity="${hash}" crossorigin="anonymous"` : '' +} + +/** `` for one stylesheet of the core package, by its path inside the package. */ +const cdnLinkTag = (file: string): string => `` + +/** `` /** `` for the core stylesheet. */ -export const cdnCssTag = (): string => `` +export const cdnCssTag = (): string => cdnLinkTag('dist/css/tabler.min.css') /** `` +export const cdnJsTag = (): string => cdnScriptTag('dist/js/tabler.min.js') + +/** `