fix: extend derived asset rewriting for web manifests to BMFont XML (#3591)

## Description:
The new hashed public asset pipeline treated `manifest.json` as a
derived asset, but BMFont XML files were still copied as raw files.

That broke bitmap fonts in production:
- the XML was loaded through the hashed asset manifest
- the XML still referenced an unhashed `file="...png"` page
- Pixi resolved that relative to the hashed XML URL
- the unhashed page file did not exist under `/_assets/...`

This PR extends the derived asset rewriting model to BMFont XML so font
page references are rewritten before hashing and emission.

## What changed

- Refactored the public asset build pipeline to distinguish:
  - raw assets hashed from source bytes
  - derived assets hashed from rewritten content
- Replaced the `manifest.json` one-off special case with a small
derived-asset registry
- Added BMFont XML derived-asset handling for `fonts/**/*.xml`
- Rewrote `<page file="...">` entries to hashed relative page paths
- Moved `_assets/asset-manifest.mjs` emission to the end of the Vite
asset sync step

Added regression coverage for:
- rewritten web manifest hashing
- BMFont XML page rewrite
- nested relative BMFont page paths
- hard failure on missing derived asset references


## Please complete the following:

- [ ] I have added screenshots for all UI updates
- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [ ] I have added relevant tests to the test directory
- [ ] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

DISCORD_USERNAME
This commit is contained in:
scamiv
2026-04-05 21:26:50 +02:00
committed by GitHub
parent 75f329fc5b
commit 31203138bc
3 changed files with 305 additions and 21 deletions
+156 -17
View File
@@ -34,6 +34,17 @@ const ROOT_PUBLIC_FILES = new Set([
const manifestCache = new Map<string, AssetManifest>();
type DerivedPublicAssetRenderContext = {
resourcesDir: string;
relativePath: string;
assetManifest: AssetManifest;
};
type DerivedPublicAssetRenderer = {
matches: (relativePath: string) => boolean;
render: (context: DerivedPublicAssetRenderContext) => string;
};
function toPosixPath(filePath: string): string {
return filePath.split(path.sep).join(path.posix.sep);
}
@@ -58,10 +69,38 @@ function createHashedAssetUrl(relativePath: string, hash: string): string {
return `/${encodeAssetPath(hashedRelativePath)}`;
}
function renderWebManifest(
function readPublicAssetText(
resourcesDir: string,
assetManifest: AssetManifest,
relativePath: string,
): string {
const sourcePath = path.join(resourcesDir, relativePath);
return fs.readFileSync(sourcePath, "utf8");
}
function resolveDerivedAssetReference(
relativePath: string,
referencePath: string,
): string {
const baseDir = path.posix.dirname(toPosixPath(relativePath));
return normalizeAssetPath(path.posix.join(baseDir, referencePath));
}
function getEmittedAssetRelativePath(
fromRelativePath: string,
targetHashedUrl: string,
): string {
const emittedFromDir = path.posix.join(
"_assets",
path.posix.dirname(toPosixPath(fromRelativePath)),
);
const emittedTargetPath = normalizeAssetPath(targetHashedUrl);
return path.posix.relative(emittedFromDir, emittedTargetPath);
}
function renderWebManifestAsset({
resourcesDir,
assetManifest,
}: DerivedPublicAssetRenderContext): string {
const sourcePath = path.join(resourcesDir, "manifest.json");
const manifest = JSON.parse(fs.readFileSync(sourcePath, "utf8")) as {
icons?: Array<{ src?: string }>;
@@ -73,6 +112,90 @@ function renderWebManifest(
return `${JSON.stringify(manifest, null, 2)}\n`;
}
function renderBitmapFontAsset({
resourcesDir,
relativePath,
assetManifest,
}: DerivedPublicAssetRenderContext): string {
const sourceXml = readPublicAssetText(resourcesDir, relativePath);
return sourceXml.replace(
/(<page\b[^>]*\bfile=)(["'])([^"']+)(["'])/g,
(
match,
prefix: string,
openQuote: string,
filePath: string,
closeQuote: string,
) => {
if (openQuote !== closeQuote) {
return match;
}
const referencedAssetPath = resolveDerivedAssetReference(
relativePath,
filePath,
);
const referencedHashedUrl = assetManifest[referencedAssetPath];
if (!referencedHashedUrl) {
throw new Error(
`Derived asset ${relativePath} references ${referencedAssetPath}, but it is missing from the asset manifest`,
);
}
const rewrittenFilePath = getEmittedAssetRelativePath(
relativePath,
referencedHashedUrl,
);
return `${prefix}${openQuote}${rewrittenFilePath}${closeQuote}`;
},
);
}
const DERIVED_PUBLIC_ASSET_RENDERERS: DerivedPublicAssetRenderer[] = [
{
matches: (relativePath) => relativePath === "manifest.json",
render: renderWebManifestAsset,
},
{
matches: (relativePath) =>
relativePath.startsWith("fonts/") && relativePath.endsWith(".xml"),
render: renderBitmapFontAsset,
},
];
function getDerivedPublicAssetRenderer(
relativePath: string,
): DerivedPublicAssetRenderer | undefined {
return DERIVED_PUBLIC_ASSET_RENDERERS.find((renderer) =>
renderer.matches(relativePath),
);
}
export function isDerivedPublicAsset(relativePath: string): boolean {
return (
getDerivedPublicAssetRenderer(normalizeAssetPath(relativePath)) !==
undefined
);
}
function renderDerivedPublicAsset(
resourcesDir: string,
relativePath: string,
assetManifest: AssetManifest,
): string | null {
const normalizedPath = normalizeAssetPath(relativePath);
const renderer = getDerivedPublicAssetRenderer(normalizedPath);
if (!renderer) {
return null;
}
return renderer.render({
resourcesDir,
relativePath: normalizedPath,
assetManifest,
});
}
export function getResourcesDir(rootDir: string = process.cwd()): string {
return path.join(rootDir, "resources");
}
@@ -114,22 +237,36 @@ export function buildPublicAssetManifest(resourcesDir: string): AssetManifest {
return cached;
}
const manifest: AssetManifest = {};
for (const relativePath of listHashedPublicAssetPaths(resourcesDir)) {
if (relativePath === "manifest.json") {
continue;
}
const hashedPublicAssetPaths = listHashedPublicAssetPaths(resourcesDir);
const rawAssetPaths = hashedPublicAssetPaths.filter(
(relativePath) => !isDerivedPublicAsset(relativePath),
);
const derivedAssetPaths = hashedPublicAssetPaths.filter((relativePath) =>
isDerivedPublicAsset(relativePath),
);
const manifest: AssetManifest = {};
for (const relativePath of rawAssetPaths) {
const absolutePath = path.join(resourcesDir, relativePath);
const hash = createContentHash(absolutePath);
manifest[relativePath] = createHashedAssetUrl(relativePath, hash);
}
const renderedWebManifest = renderWebManifest(resourcesDir, manifest);
manifest["manifest.json"] = createHashedAssetUrl(
"manifest.json",
createStringHash(renderedWebManifest),
);
for (const relativePath of derivedAssetPaths) {
const renderedAsset = renderDerivedPublicAsset(
resourcesDir,
relativePath,
manifest,
);
if (renderedAsset === null) {
throw new Error(`Missing derived asset renderer for ${relativePath}`);
}
manifest[relativePath] = createHashedAssetUrl(
relativePath,
createStringHash(renderedAsset),
);
}
manifestCache.set(resourcesDir, manifest);
return manifest;
@@ -149,11 +286,13 @@ export function createHashedPublicAssetFiles(
const outputPath = path.join(outDir, normalizeAssetPath(hashedUrl));
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
if (relativePath === "manifest.json") {
fs.writeFileSync(
outputPath,
renderWebManifest(resourcesDir, assetManifest),
);
const renderedAsset = renderDerivedPublicAsset(
resourcesDir,
relativePath,
assetManifest,
);
if (renderedAsset !== null) {
fs.writeFileSync(outputPath, renderedAsset);
continue;
}
+148 -3
View File
@@ -2,6 +2,7 @@ import fs from "fs/promises";
import os from "os";
import path from "path";
import { afterEach, describe, expect, test } from "vitest";
import { normalizeAssetPath } from "../../src/core/AssetUrls";
import {
buildPublicAssetManifest,
clearPublicAssetManifestCache,
@@ -11,6 +12,63 @@ import {
describe("PublicAssetManifest", () => {
let tempDir: string | null = null;
type TempResources = {
resourcesDir: string;
outDir: string;
};
async function createTempResources(): Promise<TempResources> {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "public-assets-"));
const resourcesDir = path.join(tempDir, "resources");
const outDir = path.join(tempDir, "static");
await fs.mkdir(resourcesDir, { recursive: true });
await fs.writeFile(path.join(resourcesDir, "manifest.json"), "{}\n");
return { resourcesDir, outDir };
}
function getExpectedRelativeEmittedPath(
fromAssetHref: string,
targetAssetHref: string,
): string {
const fromDir = path.posix.dirname(normalizeAssetPath(fromAssetHref));
const targetPath = normalizeAssetPath(targetAssetHref);
return path.posix.relative(fromDir, targetPath);
}
async function writeBitmapFontFixture(
resourcesDir: string,
xmlRelativePath: string,
pageFilePath: string,
pageContent: string = "png-v1",
): Promise<void> {
const xmlPath = path.join(resourcesDir, xmlRelativePath);
const pagePath = path.join(path.dirname(xmlPath), pageFilePath);
const xmlPageFilePath = pageFilePath.split(path.sep).join(path.posix.sep);
await fs.mkdir(path.dirname(pagePath), { recursive: true });
await fs.writeFile(
xmlPath,
[
'<?xml version="1.0"?>',
"<font>",
` <pages><page id="0" file="${xmlPageFilePath}"/></pages>`,
"</font>",
"",
].join("\n"),
);
await fs.writeFile(pagePath, pageContent);
}
async function emitHashedAsset(
outDir: string,
assetHref: string,
): Promise<string> {
return fs.readFile(
path.join(outDir, normalizeAssetPath(assetHref)),
"utf8",
);
}
afterEach(async () => {
clearPublicAssetManifestCache();
if (tempDir) {
@@ -20,9 +78,7 @@ describe("PublicAssetManifest", () => {
});
test("hashes manifest.json from its rewritten content", async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "public-assets-"));
const resourcesDir = path.join(tempDir, "resources");
const outDir = path.join(tempDir, "static");
const { resourcesDir, outDir } = await createTempResources();
await fs.mkdir(path.join(resourcesDir, "icons"), { recursive: true });
await fs.writeFile(
@@ -68,4 +124,93 @@ describe("PublicAssetManifest", () => {
expect(firstOutput).toContain(firstIconHref);
expect(firstOutput).not.toContain(secondIconHref);
});
test("rewrites BMFont XML page filenames to hashed relative paths", async () => {
const { resourcesDir, outDir } = await createTempResources();
await writeBitmapFontFixture(
resourcesDir,
path.join("fonts", "test.xml"),
"test.png",
);
const assetManifest = buildPublicAssetManifest(resourcesDir);
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest);
const xmlHref = assetManifest["fonts/test.xml"];
const pngHref = assetManifest["fonts/test.png"];
const emittedXml = await emitHashedAsset(outDir, xmlHref);
expect(emittedXml).toContain(
getExpectedRelativeEmittedPath(xmlHref, pngHref),
);
expect(emittedXml).not.toContain('file="test.png"');
});
test("BMFont XML hash changes when a referenced page image changes", async () => {
const { resourcesDir } = await createTempResources();
await writeBitmapFontFixture(
resourcesDir,
path.join("fonts", "test.xml"),
"test.png",
);
const firstManifest = buildPublicAssetManifest(resourcesDir);
await fs.writeFile(path.join(resourcesDir, "fonts", "test.png"), "png-v2");
clearPublicAssetManifestCache();
const secondManifest = buildPublicAssetManifest(resourcesDir);
expect(firstManifest["fonts/test.png"]).not.toBe(
secondManifest["fonts/test.png"],
);
expect(firstManifest["fonts/test.xml"]).not.toBe(
secondManifest["fonts/test.xml"],
);
});
test("fails when BMFont XML references a missing page image", async () => {
const { resourcesDir } = await createTempResources();
await fs.mkdir(path.join(resourcesDir, "fonts"), { recursive: true });
await fs.writeFile(
path.join(resourcesDir, "fonts", "broken.xml"),
[
'<?xml version="1.0"?>',
"<font>",
' <pages><page id="0" file="missing.png"/></pages>',
"</font>",
"",
].join("\n"),
);
expect(() => buildPublicAssetManifest(resourcesDir)).toThrow(
/missing from the asset manifest/i,
);
});
test("rewrites nested BMFont page references to the correct relative hashed path", async () => {
const { resourcesDir, outDir } = await createTempResources();
await writeBitmapFontFixture(
resourcesDir,
path.join("fonts", "nested", "atlas.xml"),
path.join("pages", "p0.png"),
"nested-png",
);
const assetManifest = buildPublicAssetManifest(resourcesDir);
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest);
const xmlHref = assetManifest["fonts/nested/atlas.xml"];
const pngHref = assetManifest["fonts/nested/pages/p0.png"];
const emittedXml = await emitHashedAsset(outDir, xmlHref);
expect(emittedXml).toContain(
getExpectedRelativeEmittedPath(xmlHref, pngHref),
);
expect(emittedXml).not.toContain('file="pages/p0.png"');
});
});
+1 -1
View File
@@ -45,8 +45,8 @@ export default defineConfig(({ mode }) => {
closeBundle() {
const outDir = path.join(__dirname, "static");
copyRootPublicFiles(resourcesDir, outDir);
writePublicAssetManifestModule(outDir, assetManifest);
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest);
writePublicAssetManifestModule(outDir, assetManifest);
},
});