Move brand images to proprietary/ and support multi-dir asset pipeline (#3662)
## Description: * Move proprietary brand images (logos, favicon) from resources/images/ to proprietary/images/ to separate open-source assets from proprietary ones * Extend the asset pipeline (PublicAssetManifest, vite.config.ts) to support multiple source directories (resources/ + proprietary/), so buildAssetUrl resolves assets from either location transparently * In dev, serve proprietary/ as a fallback middleware (registered after Vite's publicDir handler) so resources/ takes precedence when files exist in both. The idea is we could have placeholder assets placeholders that can be used by forks, and only the production build uses proprietary assets. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] 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: evan
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
@@ -1,7 +1,5 @@
|
||||
import { Howl } from "howler";
|
||||
import of4 from "../../../proprietary/sounds/music/of4.mp3";
|
||||
import openfront from "../../../proprietary/sounds/music/openfront.mp3";
|
||||
import war from "../../../proprietary/sounds/music/war.mp3";
|
||||
import { assetUrl } from "../../core/AssetUrls";
|
||||
import { EventBus } from "../../core/EventBus";
|
||||
import { UserSettings } from "../../core/game/UserSettings";
|
||||
import {
|
||||
@@ -33,19 +31,19 @@ export class SoundManager {
|
||||
this.safely("initialize background music", () => {
|
||||
this.backgroundMusic = [
|
||||
new Howl({
|
||||
src: [of4],
|
||||
src: [assetUrl("sounds/music/of4.mp3")],
|
||||
loop: false,
|
||||
onend: this.playNext.bind(this),
|
||||
volume: 0,
|
||||
}),
|
||||
new Howl({
|
||||
src: [openfront],
|
||||
src: [assetUrl("sounds/music/openfront.mp3")],
|
||||
loop: false,
|
||||
onend: this.playNext.bind(this),
|
||||
volume: 0,
|
||||
}),
|
||||
new Howl({
|
||||
src: [war],
|
||||
src: [assetUrl("sounds/music/war.mp3")],
|
||||
loop: false,
|
||||
onend: this.playNext.bind(this),
|
||||
volume: 0,
|
||||
|
||||
@@ -233,20 +233,44 @@ export function getResourcesDir(rootDir: string = process.cwd()): string {
|
||||
return path.join(rootDir, "resources");
|
||||
}
|
||||
|
||||
export function getProprietaryDir(rootDir: string = process.cwd()): string {
|
||||
return path.join(rootDir, "proprietary");
|
||||
}
|
||||
|
||||
// Scans directories with synchronous fs.existsSync — assumes a small number of sourceDirs.
|
||||
function resolveSourceDir(relativePath: string, sourceDirs: string[]): string {
|
||||
for (const dir of sourceDirs) {
|
||||
const candidate = path.join(dir, relativePath);
|
||||
if (fs.existsSync(candidate)) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Asset ${relativePath} not found in any source directory: ${sourceDirs.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSourceFile(relativePath: string, sourceDirs: string[]): string {
|
||||
return path.join(resolveSourceDir(relativePath, sourceDirs), relativePath);
|
||||
}
|
||||
|
||||
export function shouldKeepRootPublicFile(relativePath: string): boolean {
|
||||
return ROOT_PUBLIC_FILES.has(normalizeAssetPath(relativePath));
|
||||
}
|
||||
|
||||
export function listHashedPublicAssetPaths(resourcesDir: string): string[] {
|
||||
export function listHashedPublicAssetPaths(sourceDirs: string[]): string[] {
|
||||
const files = new Set<string>();
|
||||
for (const pattern of HASHED_PUBLIC_ASSET_GLOBS) {
|
||||
for (const file of globSync(pattern, {
|
||||
cwd: resourcesDir,
|
||||
nodir: true,
|
||||
dot: false,
|
||||
posix: true,
|
||||
})) {
|
||||
files.add(normalizeAssetPath(file));
|
||||
for (const dir of sourceDirs) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const pattern of HASHED_PUBLIC_ASSET_GLOBS) {
|
||||
for (const file of globSync(pattern, {
|
||||
cwd: dir,
|
||||
nodir: true,
|
||||
dot: false,
|
||||
posix: true,
|
||||
})) {
|
||||
files.add(normalizeAssetPath(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...files].sort();
|
||||
@@ -264,13 +288,14 @@ export function listRootPublicFiles(resourcesDir: string): string[] {
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function buildPublicAssetManifest(resourcesDir: string): AssetManifest {
|
||||
const cached = manifestCache.get(resourcesDir);
|
||||
export function buildPublicAssetManifest(sourceDirs: string[]): AssetManifest {
|
||||
const cacheKey = sourceDirs.join("\0");
|
||||
const cached = manifestCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const hashedPublicAssetPaths = listHashedPublicAssetPaths(resourcesDir);
|
||||
const hashedPublicAssetPaths = listHashedPublicAssetPaths(sourceDirs);
|
||||
const rawAssetPaths = hashedPublicAssetPaths.filter(
|
||||
(relativePath) => !isDerivedPublicAsset(relativePath),
|
||||
);
|
||||
@@ -280,14 +305,14 @@ export function buildPublicAssetManifest(resourcesDir: string): AssetManifest {
|
||||
|
||||
const manifest: AssetManifest = {};
|
||||
for (const relativePath of rawAssetPaths) {
|
||||
const absolutePath = path.join(resourcesDir, relativePath);
|
||||
const absolutePath = resolveSourceFile(relativePath, sourceDirs);
|
||||
const hash = createContentHash(absolutePath);
|
||||
manifest[relativePath] = createHashedAssetUrl(relativePath, hash);
|
||||
}
|
||||
|
||||
for (const relativePath of derivedAssetPaths) {
|
||||
const renderedAsset = renderDerivedPublicAsset(
|
||||
resourcesDir,
|
||||
resolveSourceDir(relativePath, sourceDirs),
|
||||
relativePath,
|
||||
manifest,
|
||||
);
|
||||
@@ -301,7 +326,7 @@ export function buildPublicAssetManifest(resourcesDir: string): AssetManifest {
|
||||
);
|
||||
}
|
||||
|
||||
manifestCache.set(resourcesDir, manifest);
|
||||
manifestCache.set(cacheKey, manifest);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
@@ -310,17 +335,18 @@ export function clearPublicAssetManifestCache(): void {
|
||||
}
|
||||
|
||||
export function createHashedPublicAssetFiles(
|
||||
resourcesDir: string,
|
||||
sourceDirs: string[],
|
||||
outDir: string,
|
||||
assetManifest: AssetManifest,
|
||||
): void {
|
||||
for (const [relativePath, hashedUrl] of Object.entries(assetManifest)) {
|
||||
const sourcePath = path.join(resourcesDir, relativePath);
|
||||
const sourceDir = resolveSourceDir(relativePath, sourceDirs);
|
||||
const sourcePath = path.join(sourceDir, relativePath);
|
||||
const outputPath = path.join(outDir, normalizeAssetPath(hashedUrl));
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
const renderedAsset = renderDerivedPublicAsset(
|
||||
resourcesDir,
|
||||
sourceDir,
|
||||
relativePath,
|
||||
assetManifest,
|
||||
);
|
||||
|
||||
@@ -39,17 +39,6 @@ vi.mock("howler", () => {
|
||||
return { Howl: MockHowl };
|
||||
});
|
||||
|
||||
// Mock music imports
|
||||
vi.mock("../../../../proprietary/sounds/music/of4.mp3", () => ({
|
||||
default: "of4.mp3",
|
||||
}));
|
||||
vi.mock("../../../../proprietary/sounds/music/openfront.mp3", () => ({
|
||||
default: "openfront.mp3",
|
||||
}));
|
||||
vi.mock("../../../../proprietary/sounds/music/war.mp3", () => ({
|
||||
default: "war.mp3",
|
||||
}));
|
||||
|
||||
// Mock the Sounds module so tests don't depend on actual asset paths
|
||||
vi.mock("../../../src/client/sound/Sounds", async (importOriginal) => {
|
||||
const actual =
|
||||
|
||||
@@ -107,11 +107,11 @@ describe("PublicAssetManifest", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const firstManifest = buildPublicAssetManifest(resourcesDir);
|
||||
const firstManifest = buildPublicAssetManifest([resourcesDir]);
|
||||
const firstManifestHref = firstManifest["manifest.json"];
|
||||
const firstIconHref = firstManifest["icons/app-icon.png"];
|
||||
|
||||
createHashedPublicAssetFiles(resourcesDir, outDir, firstManifest);
|
||||
createHashedPublicAssetFiles([resourcesDir], outDir, firstManifest);
|
||||
const firstOutput = await fs.readFile(
|
||||
path.join(outDir, firstManifestHref.slice(1)),
|
||||
"utf8",
|
||||
@@ -124,7 +124,7 @@ describe("PublicAssetManifest", () => {
|
||||
);
|
||||
clearPublicAssetManifestCache();
|
||||
|
||||
const secondManifest = buildPublicAssetManifest(resourcesDir);
|
||||
const secondManifest = buildPublicAssetManifest([resourcesDir]);
|
||||
const secondManifestHref = secondManifest["manifest.json"];
|
||||
const secondIconHref = secondManifest["icons/app-icon.png"];
|
||||
|
||||
@@ -147,8 +147,8 @@ describe("PublicAssetManifest", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const assetManifest = buildPublicAssetManifest(resourcesDir);
|
||||
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest);
|
||||
const assetManifest = buildPublicAssetManifest([resourcesDir]);
|
||||
createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest);
|
||||
|
||||
const emittedManifest = await emitHashedAsset(
|
||||
outDir,
|
||||
@@ -164,7 +164,7 @@ describe("PublicAssetManifest", () => {
|
||||
|
||||
await writeWebManifestFixture(resourcesDir, [{ src: "icons/missing.png" }]);
|
||||
|
||||
expect(() => buildPublicAssetManifest(resourcesDir)).toThrow(
|
||||
expect(() => buildPublicAssetManifest([resourcesDir])).toThrow(
|
||||
/manifest\.json references icons\/missing\.png/i,
|
||||
);
|
||||
});
|
||||
@@ -177,8 +177,8 @@ describe("PublicAssetManifest", () => {
|
||||
{ src: "data:image/png;base64,AAA" },
|
||||
]);
|
||||
|
||||
const assetManifest = buildPublicAssetManifest(resourcesDir);
|
||||
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest);
|
||||
const assetManifest = buildPublicAssetManifest([resourcesDir]);
|
||||
createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest);
|
||||
|
||||
const emittedManifest = await emitHashedAsset(
|
||||
outDir,
|
||||
@@ -198,8 +198,8 @@ describe("PublicAssetManifest", () => {
|
||||
"test.png",
|
||||
);
|
||||
|
||||
const assetManifest = buildPublicAssetManifest(resourcesDir);
|
||||
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest);
|
||||
const assetManifest = buildPublicAssetManifest([resourcesDir]);
|
||||
createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest);
|
||||
|
||||
const xmlHref = assetManifest["fonts/test.xml"];
|
||||
const pngHref = assetManifest["fonts/test.png"];
|
||||
@@ -220,12 +220,12 @@ describe("PublicAssetManifest", () => {
|
||||
"test.png",
|
||||
);
|
||||
|
||||
const firstManifest = buildPublicAssetManifest(resourcesDir);
|
||||
const firstManifest = buildPublicAssetManifest([resourcesDir]);
|
||||
|
||||
await fs.writeFile(path.join(resourcesDir, "fonts", "test.png"), "png-v2");
|
||||
clearPublicAssetManifestCache();
|
||||
|
||||
const secondManifest = buildPublicAssetManifest(resourcesDir);
|
||||
const secondManifest = buildPublicAssetManifest([resourcesDir]);
|
||||
|
||||
expect(firstManifest["fonts/test.png"]).not.toBe(
|
||||
secondManifest["fonts/test.png"],
|
||||
@@ -250,7 +250,7 @@ describe("PublicAssetManifest", () => {
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(() => buildPublicAssetManifest(resourcesDir)).toThrow(
|
||||
expect(() => buildPublicAssetManifest([resourcesDir])).toThrow(
|
||||
/missing from the asset manifest/i,
|
||||
);
|
||||
});
|
||||
@@ -265,8 +265,8 @@ describe("PublicAssetManifest", () => {
|
||||
"nested-png",
|
||||
);
|
||||
|
||||
const assetManifest = buildPublicAssetManifest(resourcesDir);
|
||||
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest);
|
||||
const assetManifest = buildPublicAssetManifest([resourcesDir]);
|
||||
createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest);
|
||||
|
||||
const xmlHref = assetManifest["fonts/nested/atlas.xml"];
|
||||
const pngHref = assetManifest["fonts/nested/pages/p0.png"];
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { defineConfig, loadEnv, type Plugin } from "vite";
|
||||
import { createHtmlPlugin } from "vite-plugin-html";
|
||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { type AssetManifest, buildAssetUrl } from "./src/core/AssetUrls";
|
||||
import {
|
||||
buildPublicAssetManifest,
|
||||
copyRootPublicFiles,
|
||||
createHashedPublicAssetFiles,
|
||||
getProprietaryDir,
|
||||
getResourcesDir,
|
||||
writePublicAssetManifestModule,
|
||||
} from "./src/server/PublicAssetManifest";
|
||||
@@ -18,12 +19,43 @@ import {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
function serveProprietaryDir(dir: string): Plugin {
|
||||
const resolvedDir = path.resolve(dir) + path.sep;
|
||||
return {
|
||||
name: "serve-proprietary-dir",
|
||||
configureServer(server) {
|
||||
// Return a function so the middleware is registered after Vite's internal
|
||||
// static-file handler (publicDir). This makes proprietary/ a fallback
|
||||
// rather than taking precedence over resources/.
|
||||
return () => {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (!req.url) return next();
|
||||
const urlPath = new URL(req.url, "http://localhost").pathname;
|
||||
const filePath = path.resolve(
|
||||
dir,
|
||||
decodeURIComponent(urlPath).replace(/^\//, ""),
|
||||
);
|
||||
if (!filePath.startsWith(resolvedDir)) return next();
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const isProduction = mode === "production";
|
||||
const resourcesDir = getResourcesDir(__dirname);
|
||||
const proprietaryDir = getProprietaryDir(__dirname);
|
||||
const sourceDirs = [resourcesDir, proprietaryDir];
|
||||
const assetManifest: AssetManifest = isProduction
|
||||
? buildPublicAssetManifest(resourcesDir)
|
||||
? buildPublicAssetManifest(sourceDirs)
|
||||
: {};
|
||||
const htmlAssetData = {
|
||||
assetManifest: JSON.stringify(assetManifest),
|
||||
@@ -45,7 +77,7 @@ export default defineConfig(({ mode }) => {
|
||||
closeBundle() {
|
||||
const outDir = path.join(__dirname, "static");
|
||||
copyRootPublicFiles(resourcesDir, outDir);
|
||||
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest);
|
||||
createHashedPublicAssetFiles(sourceDirs, outDir, assetManifest);
|
||||
writePublicAssetManifestModule(outDir, assetManifest);
|
||||
},
|
||||
});
|
||||
@@ -91,6 +123,7 @@ export default defineConfig(({ mode }) => {
|
||||
|
||||
plugins: [
|
||||
tsconfigPaths(),
|
||||
...(!isProduction ? [serveProprietaryDir(proprietaryDir)] : []),
|
||||
...(isProduction
|
||||
? []
|
||||
: [
|
||||
@@ -106,14 +139,6 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
}),
|
||||
]),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: "proprietary/*",
|
||||
dest: ".",
|
||||
},
|
||||
],
|
||||
}),
|
||||
...(isProduction ? [syncHashedPublicAssets()] : []),
|
||||
tailwindcss(),
|
||||
],
|
||||
|
||||