From 098f20e2545201de3dfb3b9d1427d9e537911206 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Wed, 7 Oct 2026 02:41:09 +0200 Subject: [PATCH] Add CSS cache keys across build, publish, and serve. --- .../ts-ssg/src/build/build-config.test.ts | 27 ++++++++ .../src/build/incremental/incremental.test.ts | 65 +++++++++++++++---- .../ts-ssg/src/build/incremental/index.ts | 5 +- packages/ts-ssg/src/build/page.ts | 3 + packages/ts-ssg/src/build/styles.ts | 9 ++- packages/ts-style/src/themeAssets.test.ts | 25 +++++++ packages/ts-style/src/themeAssets.ts | 16 +++-- 7 files changed, 132 insertions(+), 18 deletions(-) create mode 100644 packages/ts-style/src/themeAssets.test.ts diff --git a/packages/ts-ssg/src/build/build-config.test.ts b/packages/ts-ssg/src/build/build-config.test.ts index 28b86509..2da60791 100644 --- a/packages/ts-ssg/src/build/build-config.test.ts +++ b/packages/ts-ssg/src/build/build-config.test.ts @@ -1,7 +1,34 @@ +import { resolveThemeFileName, resolveThemeHref } from '@purestack/ts-style' import { describe, expect, it } from 'vitest' import { resolveBuildSiteConfig } from './build-config' describe('resolveBuildSiteConfig', () => { + it.each([true, false])( + 'keeps configured stylesheet paths stable for manifest reuse (publish %s)', + (publish) => { + const input = { + siteConfig: { + rootDir: process.cwd(), + style: { + fileName: 'site.css', + href: '/assets/site.css?custom=1#theme', + }, + }, + publish: { enabled: publish }, + } + const first = resolveBuildSiteConfig(input) + const second = resolveBuildSiteConfig(input) + expect(first.style.fileName).toBe('site.css') + expect(second.style.fileName).toBe(first.style.fileName) + expect(first.style.href).toBe(input.siteConfig.style.href) + for (const theme of first.style.themes) { + expect(resolveThemeHref(first.style.href, theme)).toBe( + `/assets/${resolveThemeFileName(first.style.fileName, theme)}?custom=1#theme`, + ) + } + }, + ) + it('applies publish overrides above site config values', () => { const publishDir = `${process.cwd()}-publish` const config = resolveBuildSiteConfig({ diff --git a/packages/ts-ssg/src/build/incremental/incremental.test.ts b/packages/ts-ssg/src/build/incremental/incremental.test.ts index a4695509..e1738eb1 100644 --- a/packages/ts-ssg/src/build/incremental/incremental.test.ts +++ b/packages/ts-ssg/src/build/incremental/incremental.test.ts @@ -32,6 +32,19 @@ async function fileExists(filePath: string) { } } +async function readThemeCss(outDir: string, theme: 'light' | 'dark') { + const assetsDir = path.join(outDir, 'assets') + const pattern = + theme === 'light' + ? /^site\.[a-z0-9]+\.css$/ + : /^site\.[a-z0-9]+\.dark\.css$/ + const files = (await fs.readdir(assetsDir)).filter((file) => + pattern.test(file), + ) + expect(files).toHaveLength(1) + return fs.readFile(path.join(assetsDir, files[0]), 'utf8') +} + async function readScriptBundle( outDir: string, pageRelPath = path.join('hosts', 'index.html'), @@ -60,6 +73,42 @@ describe('incremental builder', () => { await logger?.close() }) + it.each([true, false])( + 'builds pages linked to cache-keyed theme files (publish %s)', + async (publish) => { + await withTempDir(async (base) => { + const contentDir = path.join(base, 'content') + const publishDir = path.join(base, 'publish') + await fs.mkdir(contentDir) + await fs.writeFile(path.join(contentDir, 'index.md'), '# Home') + const builder = await createIncrementalBuilder({ + siteConfig: { + rootDir: base, + contentDir, + publishDir, + outDir: publishDir, + basePath: '/docs', + style: { fileName: 'site.css', href: '/assets/site.css' }, + }, + publish: { enabled: publish }, + }) + await builder.buildAll('publish CSS cache keys') + const manifest = await readManifest(publishDir) + expect(manifest?.styles.outputs).toHaveLength(2) + const html = await fs.readFile( + path.join(publishDir, 'index.html'), + 'utf8', + ) + for (const output of manifest?.styles.outputs ?? []) { + const fileName = path.basename(output) + expect(fileName).toMatch(/^site\.[a-z0-9]+(?:\.dark)?\.css$/) + expect(html).toContain(`/docs/assets/${fileName}`) + expect((await fs.readFile(output, 'utf8')).length).toBeGreaterThan(0) + } + }) + }, + ) + it('keeps style changes during CSS generation pending for the next request', async () => { await withTempDir(async (base) => { const contentDir = path.join(base, 'content') @@ -101,9 +150,9 @@ describe('incremental builder', () => { expect(writes).toBe(1) await builder.preparePageAssets() expect(writes).toBe(2) - expect( - await fs.readFile(path.join(outDir, 'assets', 'site.css'), 'utf8'), - ).toContain('.concurrent-style') + expect(await readThemeCss(outDir, 'light')).toContain( + '.concurrent-style', + ) await builder.preparePageAssets() expect(writes).toBe(2) } finally { @@ -374,14 +423,8 @@ describe('incremental builder', () => { await builder.buildAll('test highlightjs styles') - const lightCss = await fs.readFile( - path.join(outDir, 'assets', 'site.css'), - 'utf8', - ) - const darkCss = await fs.readFile( - path.join(outDir, 'assets', 'site.dark.css'), - 'utf8', - ) + const lightCss = await readThemeCss(outDir, 'light') + const darkCss = await readThemeCss(outDir, 'dark') expect(lightCss).not.toContain('pre.shiki.shiki-themes') expect(darkCss).not.toContain('pre.shiki.shiki-themes') diff --git a/packages/ts-ssg/src/build/incremental/index.ts b/packages/ts-ssg/src/build/incremental/index.ts index 62e2ae88..d1c857af 100644 --- a/packages/ts-ssg/src/build/incremental/index.ts +++ b/packages/ts-ssg/src/build/incremental/index.ts @@ -44,7 +44,7 @@ import { } from '../manifest' import { type BuildContext, resolveHeaderFooterHtml } from '../page' import { buildPagefindIndex } from '../pagefind' -import { ScriptCacheKeyStore } from '../script-cache-key' +import { createScriptCacheKey, ScriptCacheKeyStore } from '../script-cache-key' import type { BuildHooks, BuildInput, BuildResult } from '../site' import { writeSitemap } from '../sitemap' import { type WriteStylesResult, writeStyles } from '../styles' @@ -110,6 +110,7 @@ async function createIncrementalRuntime( : createEmptyManifest(config) const scriptCacheKeys = new ScriptCacheKeyStore(manifest.assets) const context: BuildContext = { + styleCacheKey: createScriptCacheKey(), config, // The content is prepared once, by the first build or the first change or // request; see IncrementalRuntime.ensureContentReady. @@ -295,6 +296,7 @@ class IncrementalRuntime { } buildAll = async (reason: string): Promise => { + this.context.styleCacheKey = createScriptCacheKey() const buildStartMs = Date.now() const hooks = this.resolveBuildHooks() @@ -421,6 +423,7 @@ class IncrementalRuntime { { outDir, includeHljsTheme: isHighlightJsEnabled(this.config.mdx), + cacheKey: this.context.styleCacheKey, }, style, ) diff --git a/packages/ts-ssg/src/build/page.ts b/packages/ts-ssg/src/build/page.ts index 90f5e261..454a9141 100644 --- a/packages/ts-ssg/src/build/page.ts +++ b/packages/ts-ssg/src/build/page.ts @@ -47,6 +47,8 @@ import { renderPage } from './renderer' import type { BuildHooks } from './site' export interface BuildContext { + /** Shared by generated stylesheet filenames and links for this build/session. */ + styleCacheKey?: string config: SiteConfig contentRoutes: ContentRouteIndex headerHtmlByDir?: Map @@ -399,6 +401,7 @@ async function renderPageShell(input: RenderPageShellInput): Promise { styleLinks: resolveThemeStyleLinks( context.config.style.href, context.config.style.themes, + context.styleCacheKey, ), template, templates: context.templates, diff --git a/packages/ts-ssg/src/build/styles.ts b/packages/ts-ssg/src/build/styles.ts index e66c039d..29c97a25 100644 --- a/packages/ts-ssg/src/build/styles.ts +++ b/packages/ts-ssg/src/build/styles.ts @@ -19,6 +19,7 @@ export interface WriteStylesResult { } export interface WriteStylesInput { + cacheKey?: string outDir: string includeHljsTheme?: boolean } @@ -50,7 +51,7 @@ export async function writeStyles( !pretty, includeHljsTheme, ) - const cssName = resolveThemeFileName(fileName, theme) + const cssName = resolveThemeFileName(fileName, theme, input.cacheKey) const outPath = path.join(outDir, 'assets', cssName) await ensureDir(outPath) await fs.writeFile(outPath, css) @@ -67,7 +68,11 @@ export async function writeStyles( revision, outPath: lightOutPath ?? - path.join(outDir, 'assets', resolveThemeFileName(fileName, 'light')), + path.join( + outDir, + 'assets', + resolveThemeFileName(fileName, 'light', input.cacheKey), + ), outputs: resultPaths, signature: hash.digest('hex'), } diff --git a/packages/ts-style/src/themeAssets.test.ts b/packages/ts-style/src/themeAssets.test.ts new file mode 100644 index 00000000..4ba309b8 --- /dev/null +++ b/packages/ts-style/src/themeAssets.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { resolveThemeFileName, resolveThemeStyleLinks } from './themeAssets' + +describe('cache-keyed theme assets', () => { + it('uses the same key in filenames and links while preserving URL suffixes', () => { + const links = resolveThemeStyleLinks( + '/assets/site.css?v=1#theme', + ['light', 'dark'], + 'build42', + ) + for (const link of links) { + const fileName = resolveThemeFileName('site.css', link.theme, 'build42') + expect(link.href).toBe(`/assets/${fileName}?v=1#theme`) + } + expect(links.map((link) => link.href)).toEqual([ + '/assets/site.build42.css?v=1#theme', + '/assets/site.build42.dark.css?v=1#theme', + ]) + }) + + it('keeps unkeyed paths and omitted stylesheets unchanged', () => { + expect(resolveThemeFileName('site.css', 'dark')).toBe('site.dark.css') + expect(resolveThemeStyleLinks('', ['light', 'dark'], 'build42')).toEqual([]) + }) +}) diff --git a/packages/ts-style/src/themeAssets.ts b/packages/ts-style/src/themeAssets.ts index 6fa03b0d..854a733e 100644 --- a/packages/ts-style/src/themeAssets.ts +++ b/packages/ts-style/src/themeAssets.ts @@ -58,10 +58,12 @@ export function resolveThemes( export function resolveThemeFileName( fileName: string, theme: ThemeName, + cacheKey?: string, ): string { const normalized = normalizeThemeName(theme) const hasCss = fileName.toLowerCase().endsWith('.css') - const base = hasCss ? fileName.slice(0, -4) : fileName + const name = hasCss ? fileName.slice(0, -4) : fileName + const base = cacheKey ? `${name}.${cacheKey}` : name const suffix = '.css' if (normalized === 'light') { return `${base}${suffix}` @@ -69,12 +71,17 @@ export function resolveThemeFileName( return `${base}.${normalized}${suffix}` } -export function resolveThemeHref(styleHref: string, theme: ThemeName): string { +export function resolveThemeHref( + styleHref: string, + theme: ThemeName, + cacheKey?: string, +): string { if (!styleHref) return styleHref const { path, query, hash } = splitHref(styleHref) const normalized = normalizeThemeName(theme) const hasCss = path.toLowerCase().endsWith('.css') - const base = hasCss ? path.slice(0, -4) : path + const name = hasCss ? path.slice(0, -4) : path + const base = cacheKey ? `${name}.${cacheKey}` : name const suffix = '.css' const themedPath = normalized === 'light' @@ -86,11 +93,12 @@ export function resolveThemeHref(styleHref: string, theme: ThemeName): string { export function resolveThemeStyleLinks( styleHref: string, themes: ThemeName[], + cacheKey?: string, ): ThemeStylesheetLink[] { if (!styleHref) return [] const ordered = orderThemes(themes) return ordered.map((theme) => { - const href = resolveThemeHref(styleHref, theme) + const href = resolveThemeHref(styleHref, theme, cacheKey) return { theme, href,