Skip to content

Commit 098f20e

Browse files
committed
Add CSS cache keys across build, publish, and serve.
1 parent fb42ac0 commit 098f20e

7 files changed

Lines changed: 132 additions & 18 deletions

File tree

‎packages/ts-ssg/src/build/build-config.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,34 @@
1+
import { resolveThemeFileName, resolveThemeHref } from '@purestack/ts-style'
12
import { describe, expect, it } from 'vitest'
23
import { resolveBuildSiteConfig } from './build-config'
34

45
describe('resolveBuildSiteConfig', () => {
6+
it.each([true, false])(
7+
'keeps configured stylesheet paths stable for manifest reuse (publish %s)',
8+
(publish) => {
9+
const input = {
10+
siteConfig: {
11+
rootDir: process.cwd(),
12+
style: {
13+
fileName: 'site.css',
14+
href: '/assets/site.css?custom=1#theme',
15+
},
16+
},
17+
publish: { enabled: publish },
18+
}
19+
const first = resolveBuildSiteConfig(input)
20+
const second = resolveBuildSiteConfig(input)
21+
expect(first.style.fileName).toBe('site.css')
22+
expect(second.style.fileName).toBe(first.style.fileName)
23+
expect(first.style.href).toBe(input.siteConfig.style.href)
24+
for (const theme of first.style.themes) {
25+
expect(resolveThemeHref(first.style.href, theme)).toBe(
26+
`/assets/${resolveThemeFileName(first.style.fileName, theme)}?custom=1#theme`,
27+
)
28+
}
29+
},
30+
)
31+
532
it('applies publish overrides above site config values', () => {
633
const publishDir = `${process.cwd()}-publish`
734
const config = resolveBuildSiteConfig({

‎packages/ts-ssg/src/build/incremental/incremental.test.ts‎

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ async function fileExists(filePath: string) {
3232
}
3333
}
3434

35+
async function readThemeCss(outDir: string, theme: 'light' | 'dark') {
36+
const assetsDir = path.join(outDir, 'assets')
37+
const pattern =
38+
theme === 'light'
39+
? /^site\.[a-z0-9]+\.css$/
40+
: /^site\.[a-z0-9]+\.dark\.css$/
41+
const files = (await fs.readdir(assetsDir)).filter((file) =>
42+
pattern.test(file),
43+
)
44+
expect(files).toHaveLength(1)
45+
return fs.readFile(path.join(assetsDir, files[0]), 'utf8')
46+
}
47+
3548
async function readScriptBundle(
3649
outDir: string,
3750
pageRelPath = path.join('hosts', 'index.html'),
@@ -60,6 +73,42 @@ describe('incremental builder', () => {
6073
await logger?.close()
6174
})
6275

76+
it.each([true, false])(
77+
'builds pages linked to cache-keyed theme files (publish %s)',
78+
async (publish) => {
79+
await withTempDir(async (base) => {
80+
const contentDir = path.join(base, 'content')
81+
const publishDir = path.join(base, 'publish')
82+
await fs.mkdir(contentDir)
83+
await fs.writeFile(path.join(contentDir, 'index.md'), '# Home')
84+
const builder = await createIncrementalBuilder({
85+
siteConfig: {
86+
rootDir: base,
87+
contentDir,
88+
publishDir,
89+
outDir: publishDir,
90+
basePath: '/docs',
91+
style: { fileName: 'site.css', href: '/assets/site.css' },
92+
},
93+
publish: { enabled: publish },
94+
})
95+
await builder.buildAll('publish CSS cache keys')
96+
const manifest = await readManifest(publishDir)
97+
expect(manifest?.styles.outputs).toHaveLength(2)
98+
const html = await fs.readFile(
99+
path.join(publishDir, 'index.html'),
100+
'utf8',
101+
)
102+
for (const output of manifest?.styles.outputs ?? []) {
103+
const fileName = path.basename(output)
104+
expect(fileName).toMatch(/^site\.[a-z0-9]+(?:\.dark)?\.css$/)
105+
expect(html).toContain(`/docs/assets/${fileName}`)
106+
expect((await fs.readFile(output, 'utf8')).length).toBeGreaterThan(0)
107+
}
108+
})
109+
},
110+
)
111+
63112
it('keeps style changes during CSS generation pending for the next request', async () => {
64113
await withTempDir(async (base) => {
65114
const contentDir = path.join(base, 'content')
@@ -101,9 +150,9 @@ describe('incremental builder', () => {
101150
expect(writes).toBe(1)
102151
await builder.preparePageAssets()
103152
expect(writes).toBe(2)
104-
expect(
105-
await fs.readFile(path.join(outDir, 'assets', 'site.css'), 'utf8'),
106-
).toContain('.concurrent-style')
153+
expect(await readThemeCss(outDir, 'light')).toContain(
154+
'.concurrent-style',
155+
)
107156
await builder.preparePageAssets()
108157
expect(writes).toBe(2)
109158
} finally {
@@ -374,14 +423,8 @@ describe('incremental builder', () => {
374423

375424
await builder.buildAll('test highlightjs styles')
376425

377-
const lightCss = await fs.readFile(
378-
path.join(outDir, 'assets', 'site.css'),
379-
'utf8',
380-
)
381-
const darkCss = await fs.readFile(
382-
path.join(outDir, 'assets', 'site.dark.css'),
383-
'utf8',
384-
)
426+
const lightCss = await readThemeCss(outDir, 'light')
427+
const darkCss = await readThemeCss(outDir, 'dark')
385428

386429
expect(lightCss).not.toContain('pre.shiki.shiki-themes')
387430
expect(darkCss).not.toContain('pre.shiki.shiki-themes')

‎packages/ts-ssg/src/build/incremental/index.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import {
4444
} from '../manifest'
4545
import { type BuildContext, resolveHeaderFooterHtml } from '../page'
4646
import { buildPagefindIndex } from '../pagefind'
47-
import { ScriptCacheKeyStore } from '../script-cache-key'
47+
import { createScriptCacheKey, ScriptCacheKeyStore } from '../script-cache-key'
4848
import type { BuildHooks, BuildInput, BuildResult } from '../site'
4949
import { writeSitemap } from '../sitemap'
5050
import { type WriteStylesResult, writeStyles } from '../styles'
@@ -110,6 +110,7 @@ async function createIncrementalRuntime(
110110
: createEmptyManifest(config)
111111
const scriptCacheKeys = new ScriptCacheKeyStore(manifest.assets)
112112
const context: BuildContext = {
113+
styleCacheKey: createScriptCacheKey(),
113114
config,
114115
// The content is prepared once, by the first build or the first change or
115116
// request; see IncrementalRuntime.ensureContentReady.
@@ -295,6 +296,7 @@ class IncrementalRuntime {
295296
}
296297

297298
buildAll = async (reason: string): Promise<BuildResult> => {
299+
this.context.styleCacheKey = createScriptCacheKey()
298300
const buildStartMs = Date.now()
299301
const hooks = this.resolveBuildHooks()
300302

@@ -421,6 +423,7 @@ class IncrementalRuntime {
421423
{
422424
outDir,
423425
includeHljsTheme: isHighlightJsEnabled(this.config.mdx),
426+
cacheKey: this.context.styleCacheKey,
424427
},
425428
style,
426429
)

‎packages/ts-ssg/src/build/page.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ import { renderPage } from './renderer'
4747
import type { BuildHooks } from './site'
4848

4949
export interface BuildContext {
50+
/** Shared by generated stylesheet filenames and links for this build/session. */
51+
styleCacheKey?: string
5052
config: SiteConfig
5153
contentRoutes: ContentRouteIndex
5254
headerHtmlByDir?: Map<string, string>
@@ -399,6 +401,7 @@ async function renderPageShell(input: RenderPageShellInput): Promise<string> {
399401
styleLinks: resolveThemeStyleLinks(
400402
context.config.style.href,
401403
context.config.style.themes,
404+
context.styleCacheKey,
402405
),
403406
template,
404407
templates: context.templates,

‎packages/ts-ssg/src/build/styles.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface WriteStylesResult {
1919
}
2020

2121
export interface WriteStylesInput {
22+
cacheKey?: string
2223
outDir: string
2324
includeHljsTheme?: boolean
2425
}
@@ -50,7 +51,7 @@ export async function writeStyles(
5051
!pretty,
5152
includeHljsTheme,
5253
)
53-
const cssName = resolveThemeFileName(fileName, theme)
54+
const cssName = resolveThemeFileName(fileName, theme, input.cacheKey)
5455
const outPath = path.join(outDir, 'assets', cssName)
5556
await ensureDir(outPath)
5657
await fs.writeFile(outPath, css)
@@ -67,7 +68,11 @@ export async function writeStyles(
6768
revision,
6869
outPath:
6970
lightOutPath ??
70-
path.join(outDir, 'assets', resolveThemeFileName(fileName, 'light')),
71+
path.join(
72+
outDir,
73+
'assets',
74+
resolveThemeFileName(fileName, 'light', input.cacheKey),
75+
),
7176
outputs: resultPaths,
7277
signature: hash.digest('hex'),
7378
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { resolveThemeFileName, resolveThemeStyleLinks } from './themeAssets'
3+
4+
describe('cache-keyed theme assets', () => {
5+
it('uses the same key in filenames and links while preserving URL suffixes', () => {
6+
const links = resolveThemeStyleLinks(
7+
'/assets/site.css?v=1#theme',
8+
['light', 'dark'],
9+
'build42',
10+
)
11+
for (const link of links) {
12+
const fileName = resolveThemeFileName('site.css', link.theme, 'build42')
13+
expect(link.href).toBe(`/assets/${fileName}?v=1#theme`)
14+
}
15+
expect(links.map((link) => link.href)).toEqual([
16+
'/assets/site.build42.css?v=1#theme',
17+
'/assets/site.build42.dark.css?v=1#theme',
18+
])
19+
})
20+
21+
it('keeps unkeyed paths and omitted stylesheets unchanged', () => {
22+
expect(resolveThemeFileName('site.css', 'dark')).toBe('site.dark.css')
23+
expect(resolveThemeStyleLinks('', ['light', 'dark'], 'build42')).toEqual([])
24+
})
25+
})

‎packages/ts-style/src/themeAssets.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,23 +58,30 @@ export function resolveThemes(
5858
export function resolveThemeFileName(
5959
fileName: string,
6060
theme: ThemeName,
61+
cacheKey?: string,
6162
): string {
6263
const normalized = normalizeThemeName(theme)
6364
const hasCss = fileName.toLowerCase().endsWith('.css')
64-
const base = hasCss ? fileName.slice(0, -4) : fileName
65+
const name = hasCss ? fileName.slice(0, -4) : fileName
66+
const base = cacheKey ? `${name}.${cacheKey}` : name
6567
const suffix = '.css'
6668
if (normalized === 'light') {
6769
return `${base}${suffix}`
6870
}
6971
return `${base}.${normalized}${suffix}`
7072
}
7173

72-
export function resolveThemeHref(styleHref: string, theme: ThemeName): string {
74+
export function resolveThemeHref(
75+
styleHref: string,
76+
theme: ThemeName,
77+
cacheKey?: string,
78+
): string {
7379
if (!styleHref) return styleHref
7480
const { path, query, hash } = splitHref(styleHref)
7581
const normalized = normalizeThemeName(theme)
7682
const hasCss = path.toLowerCase().endsWith('.css')
77-
const base = hasCss ? path.slice(0, -4) : path
83+
const name = hasCss ? path.slice(0, -4) : path
84+
const base = cacheKey ? `${name}.${cacheKey}` : name
7885
const suffix = '.css'
7986
const themedPath =
8087
normalized === 'light'
@@ -86,11 +93,12 @@ export function resolveThemeHref(styleHref: string, theme: ThemeName): string {
8693
export function resolveThemeStyleLinks(
8794
styleHref: string,
8895
themes: ThemeName[],
96+
cacheKey?: string,
8997
): ThemeStylesheetLink[] {
9098
if (!styleHref) return []
9199
const ordered = orderThemes(themes)
92100
return ordered.map((theme) => {
93-
const href = resolveThemeHref(styleHref, theme)
101+
const href = resolveThemeHref(styleHref, theme, cacheKey)
94102
return {
95103
theme,
96104
href,

0 commit comments

Comments
 (0)