mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:50:42 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user