diff --git a/core/.build/generate-sri.ts b/core/.build/generate-sri.ts index c567a89f8..8ac0d38c1 100644 --- a/core/.build/generate-sri.ts +++ b/core/.build/generate-sri.ts @@ -1,115 +1,129 @@ +// Writes SRI hashes for the built core files to shared/data/sri.json. +// +// Nothing consumes that file yet: the CDN snippets in the docs are shown without `integrity`, and +// Subresource Integrity is planned for v2.0. The script is kept here so the hashes are ready to be +// wired up then; it is not part of any build or workflow. Run it by hand after building core: +// +// pnpm exec tsx core/.build/generate-sri.ts +// +// Hashes describe the release the snippets would link to (@tabler/core@), so a real setup +// must run this against the published build — hashing an unreleased core/dist would pin an +// `integrity` value the browser rejects, which is worse for users than no `integrity` at all. The +// version is stored next to the hashes so the consumer can check the two match. import * as crypto from 'node:crypto' -import { readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const configFile = path.join(__dirname, '../../shared/data/sri.json') +const distDir = path.join(__dirname, '../dist') +const { version } = JSON.parse(readFileSync(path.join(__dirname, '../package.json'), 'utf8')) as { version: string } interface FileConfig { file: string configPropertyName: string } +interface SriData { + version: string + hashes: Record +} + const files: FileConfig[] = [ { - file: 'dist/css/tabler.min.css', + file: 'css/tabler.min.css', configPropertyName: 'css', }, { - file: 'dist/css/tabler.rtl.min.css', + file: 'css/tabler.rtl.min.css', configPropertyName: 'css-rtl', }, { - file: 'dist/css/tabler-flags.min.css', + file: 'css/tabler-flags.min.css', configPropertyName: 'css-flags', }, { - file: 'dist/css/tabler-flags.rtl.min.css', + file: 'css/tabler-flags.rtl.min.css', configPropertyName: 'css-flags-rtl', }, { - file: 'dist/css/tabler-marketing.min.css', + file: 'css/tabler-marketing.min.css', configPropertyName: 'css-marketing', }, { - file: 'dist/css/tabler-marketing.rtl.min.css', + file: 'css/tabler-marketing.rtl.min.css', configPropertyName: 'css-marketing-rtl', }, { - file: 'dist/css/tabler-payments.min.css', + file: 'css/tabler-payments.min.css', configPropertyName: 'css-payments', }, { - file: 'dist/css/tabler-payments.rtl.min.css', + file: 'css/tabler-payments.rtl.min.css', configPropertyName: 'css-payments-rtl', }, { - file: 'dist/css/tabler-props.min.css', + file: 'css/tabler-props.min.css', configPropertyName: 'css-props', }, { - file: 'dist/css/tabler-props.rtl.min.css', + file: 'css/tabler-props.rtl.min.css', configPropertyName: 'css-props-rtl', }, { - file: 'dist/css/tabler-themes.min.css', + file: 'css/tabler-themes.min.css', configPropertyName: 'css-themes', }, { - file: 'dist/css/tabler-themes.rtl.min.css', + file: 'css/tabler-themes.rtl.min.css', configPropertyName: 'css-themes-rtl', }, { - file: 'dist/css/tabler-socials.min.css', + file: 'css/tabler-socials.min.css', configPropertyName: 'css-socials', }, { - file: 'dist/css/tabler-socials.rtl.min.css', + file: 'css/tabler-socials.rtl.min.css', configPropertyName: 'css-socials-rtl', }, { - file: 'dist/css/tabler-vendors.min.css', + file: 'css/tabler-vendors.min.css', configPropertyName: 'css-vendors', }, { - file: 'dist/css/tabler-vendors.rtl.min.css', + file: 'css/tabler-vendors.rtl.min.css', configPropertyName: 'css-vendors-rtl', }, { - file: 'dist/js/tabler.min.js', + file: 'js/tabler.min.js', configPropertyName: 'js', }, { - file: 'dist/js/tabler-theme.min.js', + file: 'js/tabler-theme.min.js', configPropertyName: 'js-theme', }, ] function generateSRI(): void { - const sriData: Record = {} + const hashes: Record = {} for (const { file, configPropertyName } of files) { - try { - const filePath = path.join(__dirname, '..', file) - const data = readFileSync(filePath, 'utf8') + const filePath = path.join(distDir, file) - const algorithm = 'sha384' - const hash = crypto.createHash(algorithm).update(data, 'utf8').digest('base64') - const integrity = `${algorithm}-${hash}` - - console.log(`${configPropertyName}: ${integrity}`) - - sriData[configPropertyName] = integrity - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - console.error(`Error processing ${file}:`, errorMessage) - throw error + if (!existsSync(filePath)) { + throw new Error(`${filePath} is missing. Run \`pnpm --filter @tabler/core build\` first.`) } + + const integrity = `sha384-${crypto.createHash('sha384').update(readFileSync(filePath)).digest('base64')}` + + console.log(`${configPropertyName}: ${integrity}`) + + hashes[configPropertyName] = integrity } - writeFileSync(configFile, JSON.stringify(sriData, null, 2) + '\n', 'utf8') + writeFileSync(configFile, JSON.stringify({ version, hashes } satisfies SriData, null, 2) + '\n', 'utf8') } try { diff --git a/core/package.json b/core/package.json index ea0f6a113..3844f2fa1 100644 --- a/core/package.json +++ b/core/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "pnpm run watch", "dev-prepare": "pnpm run clean && pnpm run copy && concurrently \"pnpm run css-build\" \"pnpm run js-build\"", - "build": "pnpm run clean && pnpm run build-assets && pnpm run copy && pnpm run generate-sri", + "build": "pnpm run clean && pnpm run build-assets && pnpm run copy", "build-assets": "concurrently \"pnpm run css\" \"pnpm run js\"", "clean": "shx rm -rf dist demo", "css": "tsx ../.build/build-css.ts scss dist/css --rtl --minify --banner", @@ -31,7 +31,6 @@ "watch-css": "nodemon --watch scss/ --ext scss --exec \"pnpm run css-build\"", "watch-js": "nodemon --watch js/ --ext ts,js --exec \"pnpm run js-build\"", "bundlewatch": "bundlewatch", - "generate-sri": "tsx .build/generate-sri.ts", "type-check": "tsc --noEmit", "test": "concurrently \"pnpm run test:js\" \"pnpm run test:scss\"", "test:js": "vitest run", diff --git a/docs/components/CdnImportPackage.astro b/docs/components/CdnImportPackage.astro index 2d22f933d..4c395245c 100644 --- a/docs/components/CdnImportPackage.astro +++ b/docs/components/CdnImportPackage.astro @@ -1,10 +1,6 @@ --- import { Code } from 'astro:components' -import { site } from '@shared/lib/site.ts' +import { cdnPackageSnippet } from '@lib/cdn-snippets.ts' --- - -`} -/> + diff --git a/docs/components/CdnImportPlugin.astro b/docs/components/CdnImportPlugin.astro index 724e155db..642d27188 100644 --- a/docs/components/CdnImportPlugin.astro +++ b/docs/components/CdnImportPlugin.astro @@ -1,6 +1,6 @@ --- import { Code } from 'astro:components' -import { site } from '@shared/lib/site.ts' +import { cdnPluginSnippet } from '@lib/cdn-snippets.ts' interface Props { plugins: string[] @@ -9,11 +9,4 @@ interface Props { const { plugins }: Props = Astro.props --- - { - return `` - }) - .join('\n')} -/> + diff --git a/docs/content/ui/getting-started/installation.mdx b/docs/content/ui/getting-started/installation.mdx index b45868971..12f0fd6d2 100644 --- a/docs/content/ui/getting-started/installation.mdx +++ b/docs/content/ui/getting-started/installation.mdx @@ -8,7 +8,7 @@ description: 'Set up Tabler: HTML, CSS, JS, and build stunning UIs.' import Example from '@components/Example.astro' import CdnImportPackage from '@components/CdnImportPackage.astro' import { Code } from 'astro:components' -import { site } from '@shared/lib/site.ts' +import { cdnCssTag, cdnJsTag } from '@lib/cdn-snippets.ts' import Steps from '@components/Steps.astro' Using a framework? See [Tabler framework integration guides](/ui/getting-started/frameworks). @@ -53,11 +53,11 @@ Update your HTML file to include these resources: Tabler Demo - + ${cdnCssTag()}

Hello, Tabler!

- + ${cdnJsTag()} `} /> diff --git a/docs/lib/cdn-snippets.ts b/docs/lib/cdn-snippets.ts new file mode 100644 index 000000000..32572a196 --- /dev/null +++ b/docs/lib/cdn-snippets.ts @@ -0,0 +1,16 @@ +// The CDN snippets the docs hand out, in one place: the page components render them, llms.ts +// 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' + +/** `` for the core stylesheet. */ +export const cdnCssTag = (): string => `` + +/** `` + +/** Both core tags, as shown by ``. */ +export const cdnPackageSnippet = (): string => `${cdnCssTag()}\n${cdnJsTag()}` + +/** `` per plugin stylesheet, as shown by ``. */ +export const cdnPluginSnippet = (plugins: string[]): string => plugins.map((plugin) => ``).join('\n') diff --git a/docs/lib/llms.ts b/docs/lib/llms.ts index 49c0b9bbd..92dc1a6a3 100644 --- a/docs/lib/llms.ts +++ b/docs/lib/llms.ts @@ -9,6 +9,7 @@ import type { CollectionEntry } from 'astro:content' import { extractMarkedSnippet } from '@shared/lib/code-example' import { site } from '@shared/lib/site' import packageManagers from '@data/package-managers.json' +import { cdnCssTag, cdnJsTag, cdnPackageSnippet, cdnPluginSnippet } from './cdn-snippets.ts' // Lazy raw imports, same as CodeDocs.astro — node:fs paths break once this is // bundled into dist/.prerender. @@ -17,6 +18,20 @@ const jsSources = import.meta.glob('../../core/js/**/*.{js,ts}', { query: '?raw' const fence = (code: string, lang = 'html') => `\`\`\`${lang}\n${code.trim()}\n\`\`\`` +/** + * `` blocks are read as source text, never evaluated, so the few interpolations + * the docs use inside them are resolved by hand. Anything not listed here is left as written. + */ +function resolveCodeTokens(snippet: string): string { + const tokens: Record string> = { + '${site.cdnUrl}': () => site.cdnUrl, + '${cdnCssTag()}': cdnCssTag, + '${cdnJsTag()}': cdnJsTag, + } + + return Object.entries(tokens).reduce((text, [token, resolve]) => text.replaceAll(token, resolve()), snippet) +} + /** Strip the common leading indentation from a block and trim blank edges. */ const dedent = (text: string) => { const lines = text.replace(/^\n+|\s+$/g, '').split('\n') @@ -101,7 +116,7 @@ export async function mdxToMarkdown(body: string): Promise { // would otherwise be mistaken for markdown code spans by protectCode() below. let text = body.replace(/]*?code=\{`([\s\S]*?)`\}[\s\S]*?\/>/g, (match, snippet: string) => { const lang = attr(match, 'lang') ?? 'html' - return `\n${fence(snippet.replaceAll('${site.cdnUrl}', site.cdnUrl), lang)}\n` + return `\n${fence(resolveCodeTokens(snippet), lang)}\n` }) const code: string[] = [] @@ -127,13 +142,12 @@ export async function mdxToMarkdown(body: string): Promise { return `\n${fence(commands.join('\n'), 'shell')}\n` }) - text = text.replace(/]*\/>/g, () => `\n${fence(`\n`)}\n`) + text = text.replace(/]*\/>/g, () => `\n${fence(cdnPackageSnippet())}\n`) text = text.replace(/]*\/>/g, (match: string) => { - const plugins = [...match.matchAll(/'([^']+)'/g)].map((plugin) => plugin[1]) + const plugins = [...match.matchAll(/'([^']+)'/g)].map((plugin) => plugin[1]).filter((plugin): plugin is string => Boolean(plugin)) if (!plugins.length) return '' - const links = plugins.map((plugin) => ``) - return `\n${fence(links.join('\n'))}\n` + return `\n${fence(cdnPluginSnippet(plugins))}\n` }) // the blocks just produced must be protected too, for the same reason