diff --git a/scripts/assembleForgejo.ts b/scripts/assembleForgejo.ts index 415fc158..b1a42dfd 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 7b180b86..d0f2043d 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 00000000..22dc864c --- /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. +*/}} + +