Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions scripts/assembleForgejo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
}
}
Expand Down
32 changes: 32 additions & 0 deletions src/build-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, () => {
Expand Down
7 changes: 7 additions & 0 deletions templates/base/head_style.tmpl
Original file line number Diff line number Diff line change
@@ -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.
*/}}
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/index.css?v={{AssetVersion}}">
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/theme-{{ThemeName .SignedUser | PathEscape}}.css?v={{AssetVersion}}">
Loading