diff --git a/scripts/assembleForgejo.ts b/scripts/assembleForgejo.ts index e0306d23..415fc158 100644 --- a/scripts/assembleForgejo.ts +++ b/scripts/assembleForgejo.ts @@ -1,4 +1,5 @@ -import { cpSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { cpSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; const root = process.cwd(); @@ -55,3 +56,24 @@ cpSync(join(root, "dist", "assets", "fonts"), join(output, "public", "assets", " for (const file of readdirSync(join(root, "dist"))) { if (file.endsWith(".css")) cpSync(join(root, "dist", file), join(css, file)); } + +// 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. +const assets = join(output, "public", "assets"); +const stampAssetLinks = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + 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)}`; + }); + if (stamped !== source) writeFileSync(path, stamped); + } + } +}; +stampAssetLinks(templates); diff --git a/src/build-output.test.ts b/src/build-output.test.ts index adcb2a08..7b180b86 100644 --- a/src/build-output.test.ts +++ b/src/build-output.test.ts @@ -148,6 +148,26 @@ it("ships component stylesheets unchanged with valid CSS and a template referenc } }); +it("stamps every template asset link with the hash of the shipped file", async () => { + const { createHash } = await import("node:crypto"); + const output = path.join(DIST_DIR, "forgejo"); + const links = fs + .readdirSync(path.join(output, "templates"), { recursive: true }) + .filter(file => String(file).endsWith(".tmpl")) + .flatMap(file => [ + ...fs + .readFileSync(path.join(output, "templates", String(file)), "utf-8") + .matchAll(/\{\{AssetUrlPrefix\}\}\/((?:css|js)\/[^"?]+)(\?v=[0-9a-f]+)?"/g), + ]); + expect(links.length).toBeGreaterThan(0); + for (const [, asset, version] of links) { + const file = path.join(output, "public", "assets", asset); + expect(fs.existsSync(file), `template links a missing asset: ${asset}`).toBe(true); + const hash = createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + expect(version, `unversioned asset link: ${asset}`).toBe(`?v=${hash.slice(0, 10)}`); + } +}); + describe("非 auto 主题 CSS 内容验证", () => { for (const fileName of SOLID_THEME_FILES) { describe(fileName, () => { diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index fd978fa0..d5b44583 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -2,9 +2,6 @@ {{$isUserProfile := and (not .Repository) .ContextUser .ContextUser.IsIndividual (or .PageIsUserProfile .PageIsViewProjects .IsPackagesPage .IsCodePage)}} {{$isOrganization := and (not .Repository) .Org}} {{if or $isUserProfile $isOrganization}} - - -{{if $isOrganization}}{{end}}
{{end}}