mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 12:00:44 +00:00
fix: add derived asset rewriting for web manifests and BMFont XML
Hash raw public assets first, then render derived assets from the manifest, hash the rewritten content, and emit that rewritten output. This replaces the manifest.json special case with a small derived-asset registry and adds BMFont XML rewriting so <page file=...> entries point to hashed relative atlas page paths in production. Also move asset-manifest.mjs emission to the end of the Vite asset sync step and add regression tests for rewritten web manifest hashing, BMFont XML page rewrite, BMFont XML hash invalidation when page images change, nested relative BMFont page paths, and hard failure on missing derived asset references.
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