import { defineNuxtModule, useLogger } from "@nuxt/kit"; import { readdir, readFile, writeFile } from "fs/promises"; import { join, relative, parse } from "path"; import { existsSync, watch } from "fs"; type AssetsTree = { [key: string]: string | AssetsTree; }; const IMAGE_EXTENSIONS = [".png", ".webp"]; const MODELS_EXTENSIONS = [".gltf"]; const ASSETS_EXTENSIONS = [...IMAGE_EXTENSIONS, ...MODELS_EXTENSIONS]; export default defineNuxtModule({ meta: { name: "asset-generator", configKey: "assetGenerator", }, defaults: {}, async setup(_, nuxt) { const logger = useLogger("asset-generator"); const publicDir = join(nuxt.options.rootDir, "public/nds"); const templateFile = join( nuxt.options.rootDir, "app/composables/useAssets.ts.in", ); const outputFile = join( nuxt.options.rootDir, "app/composables/useAssets.ts", ); const hasExt = (filename: string, exts: string[]): boolean => { const ext = parse(filename).ext.toLowerCase(); return exts.includes(ext); }; const scanDirectory = async (dir: string): Promise => { const assets: string[] = []; const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { const assetFiles = await scanDirectory(fullPath); assets.push(...assetFiles); } else if (hasExt(fullPath, ASSETS_EXTENSIONS)) { assets.push(fullPath); } } return assets; }; const toCamelCase = (str: string): string => { return str .replace(/[-_](.)/g, (_, c) => c.toUpperCase()) .replaceAll(".", "_"); }; const buildAssetsTree = (assets: string[], baseDir: string): AssetsTree => { const tree: AssetsTree = {}; for (const assetPath of assets) { const relativePath = relative(baseDir, assetPath); const parts = relativePath.split("/"); const filename = parse(parts[parts.length - 1]!).name; let current = tree; // start at 1 to skip images/, models/ for (let i = 1; i < parts.length - 1; i += 1) { const key = toCamelCase(parts[i]!); current[key] ??= {}; current = current[key] as AssetsTree; } current[toCamelCase(filename)] = `/${relativePath}`; } return tree; }; const generatorFn = (filePath: string): string => { if (hasExt(filePath, IMAGE_EXTENSIONS)) return "createImage"; if (hasExt(filePath, MODELS_EXTENSIONS)) return "createModel"; throw new Error(`No matching generator for '${filePath}'`); }; const generateAssetsObject = (tree: AssetsTree, indent = 0): string => { const spaces = " ".repeat(indent); const entries = Object.entries(tree); if (!entries.length) return "{}"; const lines = entries.map(([key, value]) => typeof value === "string" ? `${spaces} ${key}: ${generatorFn(value)}("/nds${value}"),` : `${spaces} ${key}: ${generateAssetsObject(value, indent + 1)},`, ); return `{\n${lines.join("\n")}\n${spaces}}`; }; const generateAssetsFile = async () => { try { if (!existsSync(publicDir)) { logger.warn("No public directory found"); return; } const assets = await scanDirectory(publicDir); const assetsTree = buildAssetsTree(assets, publicDir); const assetsObject = generateAssetsObject(assetsTree); const template = await readFile(templateFile, "utf-8"); const fileContent = template .replace("{{TOTAL}}", assets.length.toString()) .replace("{{ASSETS}}", assetsObject); await writeFile(outputFile, fileContent, "utf-8"); logger.success(`Generated useAssets.ts with ${assets.length} assets`); } catch (error) { logger.error("Error generating assets file:", error); } }; nuxt.hook("build:before", async () => { await generateAssetsFile(); }); if (nuxt.options.dev) { nuxt.hook("ready", () => { watch(publicDir, { recursive: true }, async (_, filePath) => { if (filePath && hasExt(filePath, ASSETS_EXTENSIONS)) { logger.info(`Detected change: ${filePath}`); await generateAssetsFile(); } }); watch(templateFile, async () => { logger.info("Template file changed"); await generateAssetsFile(); }); }); } }, });