Add extending by binding

This commit is contained in:
Endeavorance 2025-03-07 15:01:52 -05:00
parent beff321251
commit 49e82d0a13
12 changed files with 155 additions and 102 deletions

View file

@ -4,7 +4,7 @@
"": {
"name": "@proscenium/muse",
"dependencies": {
"@proscenium/playbill": "0.0.4",
"@proscenium/playbill": "0.0.9",
"chalk": "^5.4.1",
"yaml": "^2.7.0",
"zod": "^3.24.1",
@ -37,7 +37,7 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
"@proscenium/playbill": ["@proscenium/playbill@0.0.4", "https://git.astral.camp/api/packages/proscenium/npm/%40proscenium%2Fplaybill/-/0.0.4/playbill-0.0.4.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-wLH04ac77LdCcYgkFmJwRDmRJ4W1I3W+KrbQ1oYQoTDY81Rrv+//ndx9HUMtXEDjM4oTcmhP3q4SxOLZgE744Q=="],
"@proscenium/playbill": ["@proscenium/playbill@0.0.9", "https://git.astral.camp/api/packages/proscenium/npm/%40proscenium%2Fplaybill/-/0.0.9/playbill-0.0.9.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-Wx8q/1AM5LOSaFjwoRagU/J6lAGNZz2yorLrY0TrqHyuWKwkpzzPx0EAY9bBuq/+BDiebw2n84Mp+gHFJ5JyeQ=="],
"@types/bun": ["@types/bun@1.2.3", "", { "dependencies": { "bun-types": "1.2.3" } }, "sha512-054h79ipETRfjtsCW9qJK8Ipof67Pw9bodFWmkfkaUaRiIQ1dIV2VTlheshlBx3mpKr0KeK8VqnMMCtgN9rQtw=="],

5
extend-me/binding.yaml Normal file
View file

@ -0,0 +1,5 @@
$binding: playbill
id: root
name: Root Playbill
author: "Endeavorance <hello@endeavorance.camp>"
version: 0.0.1

View file

@ -0,0 +1,7 @@
---
$define: lore
id: mortals
name: Mortals
---
Fleshy af

View file

@ -14,7 +14,7 @@
"chalk": "^5.4.1",
"yaml": "^2.7.0",
"zod": "^3.24.1",
"@proscenium/playbill": "0.0.4"
"@proscenium/playbill": "0.0.9"
},
"scripts": {
"fmt": "bunx --bun biome check --fix",

View file

@ -1,4 +1,5 @@
$binding: playbill
extends: ../extend-me/binding.yaml
id: the-great-spires
name: The Great Spires
author: "Endeavorance <hello@endeavorance.camp>"

View file

@ -11,8 +11,7 @@ $define: ability
id: identify-poison
name: Identify Poison
type: action
costs:
ap: 1
ap: 1
roll: focus
boons:
- You know the antidote to the detected poison

View file

@ -1,10 +1,17 @@
import path from "node:path";
import { Glob } from "bun";
import YAML from "yaml";
import z from "zod";
import {
getEmptyPlaybill,
type AnyResource,
type Playbill,
} from "@proscenium/playbill";
import { loadYAMLFileOrFail } from "./files";
import { loadResourceFile } from "./resource";
const BindingFileSchema = z.object({
$binding: z.literal("playbill"),
extends: z.string().optional(),
id: z.string(),
name: z.string().default("Unnamed playbill"),
author: z.string().optional(),
@ -17,25 +24,38 @@ type BindingFileContent = z.infer<typeof BindingFileSchema>;
export interface PlaybillBinding {
_raw: BindingFileContent;
// File information
bindingFilePath: string;
includedFiles: string[];
// Binding properties
id: string;
name: string;
author: string;
version: string;
description: string;
playbill: Playbill;
}
export function parseBindingFile(
text: string,
export async function loadFromBinding(
filePath: string,
): PlaybillBinding {
const parsed = YAML.parse(text);
const binding = BindingFileSchema.parse(parsed);
): Promise<PlaybillBinding> {
const yamlContent = await loadYAMLFileOrFail(filePath);
const binding = BindingFileSchema.parse(yamlContent);
const fileGlobs = binding.files;
const bindingFileDirname = filePath.split("/").slice(0, -1).join("/");
let basePlaybill: Playbill | null = null;
// If this is extending another binding, load that first
if (binding.extends) {
console.log(binding);
const extendBindingPath = path.resolve(bindingFileDirname, binding.extends);
basePlaybill = (await loadFromBinding(extendBindingPath)).playbill;
}
const allFilePaths: string[] = [];
for (const thisGlob of fileGlobs) {
@ -52,19 +72,68 @@ export function parseBindingFile(
}
const bindingFileAbsolutePath = path.resolve(filePath);
const filteredFilePaths = allFilePaths.filter((filePath) => {
const filePathsWithoutBindingFile = allFilePaths.filter((filePath) => {
return filePath !== bindingFileAbsolutePath;
});
// -- LOAD ASSOCIATED RESOURCE FILES -- //
const loadedResources: AnyResource[] = [];
for (const filepath of filePathsWithoutBindingFile) {
const loaded = await loadResourceFile(filepath);
loadedResources.push(...loaded);
}
// -- COMPILE PLAYBILL FROM RESOURCES --//
const playbill = basePlaybill ?? getEmptyPlaybill();
playbill.id = binding.id;
playbill.name = binding.name;
playbill.author = binding.author ?? "Anonymous";
playbill.version = binding.version;
playbill.description = binding.description ?? "";
for (const resource of loadedResources) {
switch (resource.$define) {
case "ability":
playbill.abilities.push(resource);
break;
case "species":
playbill.species.push(resource);
break;
case "entity":
playbill.entities.push(resource);
break;
case "item":
playbill.items.push(resource);
break;
case "tag":
playbill.tags.push(resource);
break;
case "method":
playbill.methods.push(resource);
break;
case "lore":
playbill.lore.push(resource);
break;
case "rule":
playbill.rules.push(resource);
break;
case "playbill":
throw new Error("Cannot load playbills rn dawg");
}
}
return {
_raw: binding,
bindingFilePath: filePath,
includedFiles: filteredFilePaths,
includedFiles: filePathsWithoutBindingFile,
id: binding.id,
name: binding.name,
author: binding.author ?? "Anonymous",
description: binding.description ?? "",
version: binding.version,
playbill,
};
}

View file

@ -4,7 +4,7 @@ export class MuseError extends Error {
}
}
export class ResourceFileError extends MuseError {
export class FileError extends MuseError {
filePath: string;
details?: string;
@ -18,3 +18,15 @@ export class ResourceFileError extends MuseError {
return `-- ${this.message} --\nFile Path: ${this.filePath}\nDetails: ${this.details}`;
}
}
export class MalformedResourceFileError extends FileError { }
export class FileNotFoundError extends FileError {
constructor(filePath: string) {
super("File not found", filePath);
this.message = "File not found";
this.filePath = filePath;
}
}
export class CLIError extends MuseError { }

21
src/files.ts Normal file
View file

@ -0,0 +1,21 @@
import YAML from "yaml";
import { FileError, FileNotFoundError } from "./errors";
export async function loadFileOrFail(filePath: string): Promise<string> {
const fileToLoad = Bun.file(filePath);
const fileExists = await fileToLoad.exists();
if (!fileExists) {
throw new FileNotFoundError(`File not found: ${filePath}`);
}
return await fileToLoad.text();
}
export async function loadYAMLFileOrFail(filePath: string): Promise<unknown> {
try {
return YAML.parse(await loadFileOrFail(filePath));
} catch (error) {
throw new FileError("Failed to parse YAML file", filePath, `${error}`);
}
}

View file

@ -1,34 +1,15 @@
import { parseArgs } from "node:util";
import {
type AnyResource,
ValidatedPlaybillSchema,
checkDirectives,
getEmptyPlaybill,
} from "@proscenium/playbill";
import { ValidatedPlaybillSchema, checkDirectives } from "@proscenium/playbill";
import chalk from "chalk";
import { parseBindingFile } from "./binding";
import { loadResourceFile } from "./resource";
import { loadFromBinding } from "./binding";
import { usage } from "./usage";
import { ResourceFileError } from "./errors";
import { CLIError, MuseError } from "./errors";
enum ExitCode {
Success = 0,
Error = 1,
}
class CLIError extends Error { }
async function loadFileOrFail(filePath: string): Promise<string> {
const fileToLoad = Bun.file(filePath);
const fileExists = await fileToLoad.exists();
if (!fileExists) {
throw new Error(`File not found: ${filePath}`);
}
return await fileToLoad.text();
}
async function main(): Promise<number> {
// -- ARGUMENT PARSING -- //
const { values: options, positionals: args } = parseArgs({
@ -97,68 +78,15 @@ async function main(): Promise<number> {
verboseLog(`Building Playbill with binding: ${bindingPath}`);
const bindingFileContent = await loadFileOrFail(bindingPath);
const binding = parseBindingFile(bindingFileContent, bindingPath);
const binding = await loadFromBinding(bindingPath);
verboseLog(
`↳ Binding loaded with ${binding.includedFiles.length} associated resources`,
);
// -- RESOURCE FILES -- //
verboseLog("Loading resources");
const loadedResources: AnyResource[] = [];
for (const filepath of binding.includedFiles) {
verboseLog(`↳ Loading ${filepath}`);
const loaded = await loadResourceFile(filepath);
verboseLog(` ↳ Loaded ${loaded.length} resources`);
loadedResources.push(...loaded);
}
verboseLog("Constructing playbill");
// -- COMPILE RESOURCES --//
const playbill = getEmptyPlaybill();
playbill.id = binding.id;
playbill.name = binding.name;
playbill.author = binding.author;
playbill.version = binding.version;
playbill.description = binding.description;
for (const resource of loadedResources) {
switch (resource.$define) {
case "ability":
playbill.abilities.push(resource);
break;
case "species":
playbill.species.push(resource);
break;
case "entity":
playbill.entities.push(resource);
break;
case "item":
playbill.items.push(resource);
break;
case "tag":
playbill.tags.push(resource);
break;
case "method":
playbill.methods.push(resource);
break;
case "lore":
playbill.lore.push(resource);
break;
case "rule":
playbill.rules.push(resource);
break;
case "playbill":
throw new Error("Cannot load playbills rn dawg");
}
}
// -- VALDATE PLAYBILL -- //
verboseLog("Validating playbill");
const validatedPlaybill = ValidatedPlaybillSchema.safeParse(playbill);
const validatedPlaybill = ValidatedPlaybillSchema.safeParse(binding.playbill);
if (!validatedPlaybill.success) {
console.error("Error validating playbill");
@ -202,7 +130,7 @@ try {
const exitCode = await main();
process.exit(exitCode);
} catch (error) {
if (error instanceof ResourceFileError) {
if (error instanceof MuseError) {
console.error(chalk.red(error.fmt()));
} else {
console.error("An unexpected error occurred");

View file

@ -5,8 +5,9 @@ import {
} from "@proscenium/playbill";
import YAML, { YAMLParseError } from "yaml";
import { ZodError } from "zod";
import { ResourceFileError } from "./errors";
import { MalformedResourceFileError } from "./errors";
import { extractFrontmatter, extractMarkdown } from "./markdown";
import { loadFileOrFail } from "./files";
type FileFormat = "yaml" | "markdown";
@ -18,13 +19,13 @@ function loadYamlResourceFile(filePath: string, text: string): AnyResource[] {
const parsedDocs = YAML.parseAllDocuments(text);
if (parsedDocs.some((doc) => doc.toJS() === null)) {
throw new ResourceFileError("Encountered NULL resource", filePath);
throw new MalformedResourceFileError("Encountered NULL resource", filePath);
}
const errors = parsedDocs.flatMap((doc) => doc.errors);
if (errors.length > 0) {
throw new ResourceFileError(
throw new MalformedResourceFileError(
"Error parsing YAML resource",
filePath,
errors.map((e) => e.message).join(", "),
@ -35,10 +36,20 @@ function loadYamlResourceFile(filePath: string, text: string): AnyResource[] {
for (const doc of parsedDocs) {
const raw = doc.toJS();
const parsed = AnyResourceSchema.parse(raw);
const type = parsed.$define;
const parsed = AnyResourceSchema.safeParse(raw);
if (!parsed.success) {
throw new MalformedResourceFileError(
"Failed to parse resource",
filePath,
`Schema validation failed: ${parsed.error.message}`,
);
}
const parsedResource = parsed.data;
const type = parsedResource.$define;
const schemaToUse = ResourceMap[type];
const validated = schemaToUse.parse(parsed);
const validated = schemaToUse.parse(parsedResource);
collection.push(validated);
}
@ -62,7 +73,7 @@ function loadMarkdownResourceFile(
return [parsed];
} catch (e) {
if (e instanceof YAMLParseError) {
throw new ResourceFileError(
throw new MalformedResourceFileError(
"Error parsing Markdown frontmatter",
filePath,
e.message,
@ -70,7 +81,7 @@ function loadMarkdownResourceFile(
}
if (e instanceof ZodError) {
throw new ResourceFileError(
throw new MalformedResourceFileError(
"Error parsing resource file",
filePath,
e.message,
@ -92,8 +103,7 @@ function loadMarkdownResourceFile(
export async function loadResourceFile(
filePath: string,
): Promise<AnyResource[]> {
const file = Bun.file(filePath);
const text = await file.text();
const text = await loadFileOrFail(filePath);
const format = determineFileFormat(filePath);
switch (format) {

View file

@ -13,5 +13,6 @@ Options:
--write Write the output to a file. If not specified, outputs to stdout
--outfile Specify the output file path [default: playbill.json]
--verbose, -v Verbose output
--minify, -m Minify the output JSON
--help, -h Show this help message
`.trim();