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); }, });