import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig, loadEnv, type Plugin } from "vite"; import { ART_PACKS, PACK_IDS, resolvePacks, unknownPacks, } from "./src/protocol/castPacks.js"; import { CATALOG_FILE, catalogFromManifests, docAsset, droppedAvatarAtlases, SKILL_DOCS, SKILL_REFERENCE, SKILL_THUMBS, shippedManifests, } from "./src/studio/catalogJson.js"; import { artUrls, moveArt, rewriteArtUrls } from "./tools/artHash.js"; import { type OperatorValues, robotsTxt, sitemapXml, substituteOperator, } from "./tools/operatorHtml.js"; const entry = (file: string): string => fileURLToPath(new URL(file, import.meta.url)); const AVATAR_MANIFEST = "assets/avatars/manifest.json"; const BACKDROP_MANIFEST = "assets/backgrounds/manifest.json"; // a copy of public/ holding only the shipped cast, handed to vite as publicDir function shippedPublicDir(mode: string): string { const value = loadEnv(mode, entry("."), "").CHARACTER_PACKS; const unknown = unknownPacks(value); if (unknown.length > 0) throw new Error( `CHARACTER_PACKS names no such pack: ${unknown.join(", ")}. Use ${PACK_IDS.join(", ")}, all, or none`, ); const packs = resolvePacks(value); const source = entry("public"); const read = (file: string) => readFileSync(join(source, file), "utf8"); const dropped = new Set([ ...droppedAvatarAtlases(read(AVATAR_MANIFEST), packs).map((file) => resolve(source, "assets/avatars", file), ), ...ART_PACKS.filter((pack) => !packs.has(pack.id)).flatMap((pack) => pack.backdrops.map((name) => resolve(source, "assets/backgrounds", `${name}.png`), ), ), ]); const staged = entry("node_modules/.cache/art-packs/public"); rmSync(staged, { recursive: true, force: true }); cpSync(source, staged, { recursive: true, filter: (file) => !dropped.has(resolve(file)), }); const manifests = shippedManifests( read(AVATAR_MANIFEST), read(BACKDROP_MANIFEST), packs, ); const moved = moveArt( staged, artUrls(manifests.avatars, manifests.backgrounds), ); writeFileSync( join(staged, AVATAR_MANIFEST), rewriteArtUrls(manifests.avatars, moved), ); writeFileSync( join(staged, BACKDROP_MANIFEST), rewriteArtUrls(manifests.backgrounds, moved), ); writeFileSync( join(staged, CATALOG_FILE), JSON.stringify( catalogFromManifests( manifests.avatars, manifests.backgrounds, readFileSync(entry(`${SKILL_REFERENCE}/cast.md`), "utf8"), ), ), ); for (const doc of SKILL_DOCS) writeFileSync( join(staged, docAsset(doc)), readFileSync(entry(`${SKILL_REFERENCE}/${doc}`), "utf8"), ); for (const thumb of SKILL_THUMBS) { const dest = join(staged, docAsset(thumb)); mkdirSync(join(dest, ".."), { recursive: true }); writeFileSync(dest, readFileSync(entry(`${SKILL_REFERENCE}/${thumb}`))); } return staged; } // the empty prefix takes plain names, so Workers Builds vars and a local .env read the same function operatorPages(): Plugin { let values: OperatorValues = {}; return { name: "operator-pages", configResolved(config) { values = loadEnv(config.mode, config.root, ""); }, transformIndexHtml: { order: "pre", handler: (html) => substituteOperator(html, values), }, generateBundle() { this.emitFile({ type: "asset", fileName: "robots.txt", source: robotsTxt(values.SITE_ORIGIN), }); const sitemap = sitemapXml(values.SITE_ORIGIN); if (sitemap) this.emitFile({ type: "asset", fileName: "sitemap.xml", source: sitemap, }); }, }; } export default defineConfig(({ mode }) => ({ publicDir: shippedPublicDir(mode), plugins: [operatorPages()], build: { // art keeps /assets, so the immutable cache header covers these hashed bundles alone assetsDir: "build", rollupOptions: { input: { main: entry("index.html"), studio: entry("studio.html"), legal: entry("legal.html"), report: entry("report.html"), agentSkill: entry("agent-skill.html"), }, }, }, // so `vite` (HMR) can borrow the worker's /api + room websockets from a `wrangler dev` on :8787 server: { proxy: { "/api": { target: "http://127.0.0.1:8787", changeOrigin: true, ws: true, }, }, }, }));