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}}