From 394c08bd8a6981cee3e020859e6b0cde36df5066 Mon Sep 17 00:00:00 2001 From: whereareiam Date: Sat, 3 Oct 2026 18:01:23 +0200 Subject: [PATCH] Templates: Version the theme stylesheet so updates bypass the browser cache Template scripts and component stylesheets are already linked by content version, but the theme's main stylesheet was not. Forgejo links it with its own release as the only version and lets browsers cache it for six hours, so after a theme update the new markup rendered with the previous stylesheet until the cache expired or the page was reloaded without it. The theme now overrides Forgejo's two-line head_style partial. It is identical except that the build appends a version of all theme files to the theme link. The auto themes import their light and dark files by a fixed path, so each of those imports gets the version of the file it loads. Co-Authored-By: Claude Opus 5.5 --- scripts/assembleForgejo.ts | 44 +++++++++++++++++++++++++++------- src/build-output.test.ts | 32 +++++++++++++++++++++++++ templates/base/head_style.tmpl | 7 ++++++ 3 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 templates/base/head_style.tmpl diff --git a/scripts/assembleForgejo.ts b/scripts/assembleForgejo.ts index 415fc15..b1a42df 100644 --- a/scripts/assembleForgejo.ts +++ b/scripts/assembleForgejo.ts @@ -53,9 +53,38 @@ for (const file of readdirSync(join(root, "public", "assets", "js"))) { cpSync(join(root, "public", "assets", "js", file), join(js, file)); } cpSync(join(root, "dist", "assets", "fonts"), join(output, "public", "assets", "fonts"), { recursive: true }); -for (const file of readdirSync(join(root, "dist"))) { - if (file.endsWith(".css")) cpSync(join(root, "dist", file), join(css, file)); +const contentVersion = (...files: string[]): string => { + const hash = createHash("sha256"); + for (const file of files) hash.update(readFileSync(file)); + return hash.digest("hex").slice(0, 10); +}; + +// Forgejo lets browsers cache theme files for hours. The auto themes import their light and dark files by a +// fixed path, so version each import with the content of the file it loads. +const dist = join(root, "dist"); +const themeFiles = readdirSync(dist) + .filter(file => /^theme-.+\.css$/.test(file)) + .sort(); +for (const file of themeFiles) { + const source = readFileSync(join(dist, file), "utf-8"); + const stamped = source.replace( + /@import "\.\/(theme-[^"?]+\.css)(?:\?v=[0-9a-f]+)?"/g, + (_match, imported: string) => `@import "./${imported}?v=${contentVersion(join(dist, imported))}"` + ); + if (stamped !== source) writeFileSync(join(dist, file), stamped); } +for (const file of readdirSync(dist)) { + if (file.endsWith(".css")) cpSync(join(dist, file), join(css, file)); +} + +// Forgejo links the selected theme with its own release as the only version. The theme's head_style override +// gets the version of all theme files appended, so an updated theme is fetched instead of served from cache. +const headStyle = join(templates, "base", "head_style.tmpl"); +const themeVersion = contentVersion(...themeFiles.map(file => join(dist, file))); +const themeLink = /(css\/theme-\{\{[^"]+\}\}\.css\?v=\{\{AssetVersion\}\})"/; +const headStyleSource = readFileSync(headStyle, "utf-8"); +if (!themeLink.test(headStyleSource)) throw new Error(`No theme stylesheet link to version in ${headStyle}`); +writeFileSync(headStyle, headStyleSource.replace(themeLink, `$1-${themeVersion}"`)); // Forgejo serves custom assets with a long max-age and the templates link them by a fixed path, so a browser // keeps running the previous build's scripts against the new markup. Stamp each link with its content hash. @@ -66,12 +95,11 @@ const stampAssetLinks = (directory: string): void => { if (entry.isDirectory()) stampAssetLinks(path); else if (entry.name.endsWith(".tmpl")) { const source = readFileSync(path, "utf-8"); - const stamped = source.replace(/\{\{AssetUrlPrefix\}\}\/((?:css|js)\/[^"'?\s]+\.(?:css|js))/g, (link, asset) => { - const hash = createHash("sha256") - .update(readFileSync(join(assets, asset))) - .digest("hex"); - return `${link}?v=${hash.slice(0, 10)}`; - }); + // Links that already carry a version, such as Forgejo's own stylesheet, are left alone. + const stamped = source.replace( + /\{\{AssetUrlPrefix\}\}\/((?:css|js)\/[^"'?\s]+\.(?:css|js))(?!\?)/g, + (link, asset) => `${link}?v=${contentVersion(join(assets, asset))}` + ); if (stamped !== source) writeFileSync(path, stamped); } } diff --git a/src/build-output.test.ts b/src/build-output.test.ts index 7b180b8..d0f2043 100644 --- a/src/build-output.test.ts +++ b/src/build-output.test.ts @@ -168,6 +168,38 @@ it("stamps every template asset link with the hash of the shipped file", async ( } }); +it("versions the theme stylesheet link and the auto themes' imports", async () => { + const { createHash } = await import("node:crypto"); + const version = (...files: string[]) => { + const hash = createHash("sha256"); + for (const file of files) hash.update(fs.readFileSync(file)); + return hash.digest("hex").slice(0, 10); + }; + const themeFiles = fs + .readdirSync(DIST_DIR) + .filter(name => /^theme-.+\.css$/.test(name)) + .sort() + .map(name => path.join(DIST_DIR, name)); + + const headStyle = fs.readFileSync(path.join(DIST_DIR, "forgejo", "templates", "base", "head_style.tmpl"), "utf-8"); + expect(headStyle).toContain('href="{{AssetUrlPrefix}}/css/index.css?v={{AssetVersion}}"'); + expect(headStyle).toContain( + `href="{{AssetUrlPrefix}}/css/theme-{{ThemeName .SignedUser | PathEscape}}.css?v={{AssetVersion}}-${version(...themeFiles)}"` + ); + + const autoThemes = themeFiles.filter(file => file.endsWith("-auto.css")); + expect(autoThemes.length).toBeGreaterThan(0); + for (const file of autoThemes) { + const imports = [...fs.readFileSync(file, "utf-8").matchAll(/@import "\.\/([^"?]+)(\?v=[0-9a-f]+)?"/g)]; + expect(imports.length, path.basename(file)).toBeGreaterThan(0); + for (const [, imported, query] of imports) + expect(query, `${path.basename(file)} imports ${imported}`).toBe(`?v=${version(path.join(DIST_DIR, imported))}`); + expect(fs.readFileSync(path.join(DIST_DIR, "forgejo", "public", "assets", "css", path.basename(file)))).toEqual( + fs.readFileSync(file) + ); + } +}); + describe("非 auto 主题 CSS 内容验证", () => { for (const fileName of SOLID_THEME_FILES) { describe(fileName, () => { diff --git a/templates/base/head_style.tmpl b/templates/base/head_style.tmpl new file mode 100644 index 0000000..22dc864 --- /dev/null +++ b/templates/base/head_style.tmpl @@ -0,0 +1,7 @@ +{{/* + Forgejo's own partial, kept identical except for the theme link's version. Forgejo lets browsers cache theme + files for hours and versions this link with its own release only, so an updated theme kept rendering with the + previous stylesheet. The build appends the theme's content version after AssetVersion. +*/}} + +