From 7d5ad0d0ac617656a383206d8051977b44550ebe Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:10:41 +0200 Subject: [PATCH] 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 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. --- src/server/PublicAssetManifest.ts | 173 ++++++++++++++++++++--- tests/server/PublicAssetManifest.test.ts | 151 +++++++++++++++++++- vite.config.ts | 2 +- 3 files changed, 305 insertions(+), 21 deletions(-) diff --git a/src/server/PublicAssetManifest.ts b/src/server/PublicAssetManifest.ts index e46f37e50..17d027ac6 100644 --- a/src/server/PublicAssetManifest.ts +++ b/src/server/PublicAssetManifest.ts @@ -34,6 +34,17 @@ const ROOT_PUBLIC_FILES = new Set([ const manifestCache = new Map(); +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( + /(]*\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; } diff --git a/tests/server/PublicAssetManifest.test.ts b/tests/server/PublicAssetManifest.test.ts index 60bd46b7d..83b9de105 100644 --- a/tests/server/PublicAssetManifest.test.ts +++ b/tests/server/PublicAssetManifest.test.ts @@ -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 { + 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 { + 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, + [ + '', + "", + ` `, + "", + "", + ].join("\n"), + ); + await fs.writeFile(pagePath, pageContent); + } + + async function emitHashedAsset( + outDir: string, + assetHref: string, + ): Promise { + 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"), + [ + '', + "", + ' ', + "", + "", + ].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"'); + }); }); diff --git a/vite.config.ts b/vite.config.ts index e53544896..c8281969b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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); }, });