153 lines
4.9 KiB
TypeScript
153 lines
4.9 KiB
TypeScript
import { z } from "zod";
|
|
import { readFileSync } from "node:fs";
|
|
import { basename, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const personaAssetPlacements = [
|
|
"title",
|
|
"portal",
|
|
"frame",
|
|
"buttonIdle",
|
|
"buttonHover",
|
|
"buttonPressed",
|
|
"suggestion",
|
|
"composer",
|
|
"message",
|
|
"metrics",
|
|
"divider",
|
|
"pause",
|
|
"atmosphere",
|
|
"stopButton",
|
|
] as const;
|
|
export type PersonaAssetPlacement = (typeof personaAssetPlacements)[number];
|
|
|
|
const AssetSchema = z.object({
|
|
name: z.string().min(1),
|
|
prompt: z.string().min(1),
|
|
publicPath: z.string(),
|
|
outputPath: z.string(),
|
|
usage: z.array(z.string().min(1)).min(1),
|
|
treatment: z.string().min(1),
|
|
composition: z.string().min(1),
|
|
owner: z.string(),
|
|
});
|
|
const WorldAssetSchema = AssetSchema.extend({
|
|
placements: z.record(z.enum(personaAssetPlacements), AssetSchema),
|
|
});
|
|
export type WorldAsset = z.infer<typeof WorldAssetSchema>;
|
|
|
|
type PlannedAsset = z.infer<typeof AssetSchema> & {
|
|
persona: string;
|
|
placement: "world" | PersonaAssetPlacement;
|
|
};
|
|
|
|
export type WorldAssetReport = {
|
|
planned: number;
|
|
assets: Array<PlannedAsset & { status: "planned"; filename: string }>;
|
|
};
|
|
|
|
/** Reads only the manifest: planned assets need not exist to be integrated. */
|
|
export function readWorldAssets(projectRoot = resolve()): Record<string, WorldAsset> {
|
|
const manifestPath = resolve(projectRoot, "src/apps/product/content/persona-assets.json");
|
|
return z
|
|
.record(z.string(), WorldAssetSchema)
|
|
.parse(JSON.parse(readFileSync(manifestPath, "utf8")));
|
|
}
|
|
|
|
function flattenWorldAssets(assets: Record<string, WorldAsset>): PlannedAsset[] {
|
|
return Object.entries(assets).flatMap(([persona, world]) => {
|
|
const { placements, ...asset } = world;
|
|
return [
|
|
{ ...asset, persona, placement: "world" as const },
|
|
...personaAssetPlacements.map((placement) => ({
|
|
...placements[placement],
|
|
persona,
|
|
placement,
|
|
})),
|
|
];
|
|
});
|
|
}
|
|
|
|
export function validateWorldAssets(assets: Record<string, WorldAsset>): string[] {
|
|
const result = z.record(z.string(), WorldAssetSchema).safeParse(assets);
|
|
if (!result.success) {
|
|
return result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
|
|
}
|
|
const errors: string[] = [];
|
|
const names = new Set<string>();
|
|
const publicPaths = new Set<string>();
|
|
|
|
for (const asset of flattenWorldAssets(result.data)) {
|
|
const { persona, placement, name, publicPath, outputPath, owner } = asset;
|
|
const key = `${persona}.${placement}`;
|
|
const expectedOwner = `src/apps/product/personas/${persona}/persona.ts`;
|
|
if (!/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/.test(name)) {
|
|
errors.push(`${key}: name must be a stable lowercase filename stem`);
|
|
}
|
|
if (!publicPath.startsWith("/assets/generations/")) {
|
|
errors.push(`${key}: publicPath must be under /assets/generations`);
|
|
}
|
|
if (publicPath.split("/").includes("..") || publicPath.includes("\\")) {
|
|
errors.push(`${key}: publicPath must not traverse directories`);
|
|
}
|
|
if (outputPath !== `public${publicPath}`) {
|
|
errors.push(`${key}: outputPath must equal public${publicPath}`);
|
|
}
|
|
if (!basename(outputPath).startsWith(`${name}.`)) {
|
|
errors.push(`${key}: output filename must start with ${name}.`);
|
|
}
|
|
if (owner !== expectedOwner) {
|
|
errors.push(`${key}: owner must equal ${expectedOwner}`);
|
|
}
|
|
if (names.has(name)) errors.push(`${key}: duplicate name ${name}`);
|
|
if (publicPaths.has(publicPath)) {
|
|
errors.push(`${key}: duplicate publicPath ${publicPath}`);
|
|
}
|
|
names.add(name);
|
|
publicPaths.add(publicPath);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
export function reportWorldAssets(projectRoot = resolve()): WorldAssetReport {
|
|
const assets = flattenWorldAssets(readWorldAssets(projectRoot)).map((asset) => ({
|
|
...asset,
|
|
status: "planned" as const,
|
|
filename: basename(asset.outputPath),
|
|
}));
|
|
return { planned: assets.length, assets };
|
|
}
|
|
|
|
function formatReport(report: WorldAssetReport): string {
|
|
const rows = report.assets.map((asset) =>
|
|
[
|
|
`[${asset.status}] ${asset.persona}.${asset.placement}`,
|
|
`name: ${asset.name}`,
|
|
`file: ${asset.filename}`,
|
|
`public: ${asset.publicPath}`,
|
|
`repository: ${asset.outputPath}`,
|
|
`owner: ${asset.owner}`,
|
|
`uses: ${asset.usage.join(", ")}`,
|
|
`treatment: ${asset.treatment}`,
|
|
`composition: ${asset.composition}`,
|
|
].join("\n "),
|
|
);
|
|
return [`Planned: ${report.planned}`, ...rows].join("\n");
|
|
}
|
|
|
|
function main(): void {
|
|
const errors = validateWorldAssets(readWorldAssets());
|
|
if (errors.length > 0) {
|
|
console.error(
|
|
`Invalid persona asset manifest:\n${errors.map((error) => `- ${error}`).join("\n")}`,
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const report = reportWorldAssets();
|
|
console.log(
|
|
process.argv.includes("--json") ? JSON.stringify(report, null, 2) : formatReport(report),
|
|
);
|
|
}
|
|
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
|