Muse owns muse-shaped files now
This commit is contained in:
parent
3e2ab6ec73
commit
bf444ccb1a
23 changed files with 1288 additions and 201 deletions
|
@ -1,19 +1,24 @@
|
|||
import path from "node:path";
|
||||
import {
|
||||
UnvalidatedPlaybillSchema,
|
||||
type UnknownComponent,
|
||||
type UnvalidatedPlaybill,
|
||||
type Ability,
|
||||
type Blueprint,
|
||||
type Item,
|
||||
type Method,
|
||||
Playbill,
|
||||
type Resource,
|
||||
type Rule,
|
||||
type Species,
|
||||
} from "@proscenium/playbill";
|
||||
import { Glob } from "bun";
|
||||
import z from "zod";
|
||||
import { FileNotFoundError } from "#lib/errors";
|
||||
import { loadYAMLFileOrFail } from "#util/files";
|
||||
import { loadResourceFile } from "./resource";
|
||||
import { loadYAMLFileOrFail } from "#util";
|
||||
import { ComponentFile } from "./component-file";
|
||||
import { FileNotFoundError } from "./errors";
|
||||
|
||||
const BindingFileSchema = z.object({
|
||||
const BindingSchema = z.object({
|
||||
extend: z.string().optional(),
|
||||
|
||||
id: z.string(),
|
||||
id: z.string().default("playbill"),
|
||||
name: z.string().default("Unnamed playbill"),
|
||||
author: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
|
@ -23,10 +28,10 @@ const BindingFileSchema = z.object({
|
|||
styles: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type BindingFileContent = z.infer<typeof BindingFileSchema>;
|
||||
type Binding = z.infer<typeof BindingSchema>;
|
||||
|
||||
export interface PlaybillBinding {
|
||||
_raw: BindingFileContent;
|
||||
export interface BoundPlaybill {
|
||||
_raw: Binding;
|
||||
|
||||
// File information
|
||||
bindingFilePath: string;
|
||||
|
@ -40,11 +45,20 @@ export interface PlaybillBinding {
|
|||
version: string;
|
||||
description: string;
|
||||
|
||||
styles?: string[];
|
||||
styles?: string;
|
||||
|
||||
playbill: UnvalidatedPlaybill;
|
||||
playbill: Playbill;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a path, find the binding.yaml file
|
||||
* If the path is to a directory, check for a binding.yaml inside
|
||||
* If the path is to a file, check if it exists and is a binding.yaml
|
||||
* Otherwise throw an error
|
||||
*
|
||||
* @param bindingPath - The path to the binding file
|
||||
* @returns The absolute resolved path to the binding file
|
||||
*/
|
||||
export async function resolveBindingPath(bindingPath: string): Promise<string> {
|
||||
// If the path does not specify a filename, use binding.yaml
|
||||
const inputLocation = Bun.file(bindingPath);
|
||||
|
@ -66,29 +80,40 @@ export async function resolveBindingPath(bindingPath: string): Promise<string> {
|
|||
|
||||
export async function loadFromBinding(
|
||||
bindingPath: string,
|
||||
): Promise<PlaybillBinding> {
|
||||
): Promise<BoundPlaybill> {
|
||||
const resolvedBindingPath = await resolveBindingPath(bindingPath);
|
||||
const yamlContent = await loadYAMLFileOrFail(resolvedBindingPath);
|
||||
const binding = BindingFileSchema.parse(yamlContent);
|
||||
const binding = BindingSchema.parse(yamlContent);
|
||||
const fileGlobs = binding.files;
|
||||
const bindingFileDirname = path.dirname(resolvedBindingPath);
|
||||
|
||||
let playbill: UnvalidatedPlaybill = UnvalidatedPlaybillSchema.parse({
|
||||
$define: "playbill",
|
||||
id: "blank",
|
||||
name: "Unnamed playbill",
|
||||
description: "Unnamed Playbill",
|
||||
version: "0.0.1",
|
||||
});
|
||||
const playbill = new Playbill();
|
||||
|
||||
// If this is extending another binding, load that first
|
||||
if (binding.extend) {
|
||||
const pathFromHere = path.resolve(bindingFileDirname, binding.extend);
|
||||
const extendBindingPath = await resolveBindingPath(pathFromHere);
|
||||
const loadedBinding = await loadFromBinding(extendBindingPath);
|
||||
playbill = loadedBinding.playbill;
|
||||
playbill.extend(loadedBinding.playbill);
|
||||
}
|
||||
|
||||
// Load any specified stylesheets
|
||||
let styles = "";
|
||||
if (binding.styles && binding.styles.length > 0) {
|
||||
for (const style of binding.styles) {
|
||||
const cssFilePath = path.resolve(bindingFileDirname, style);
|
||||
const cssFile = Bun.file(cssFilePath);
|
||||
const cssFileExists = await cssFile.exists();
|
||||
|
||||
if (cssFileExists) {
|
||||
styles += `${await cssFile.text()}\n`;
|
||||
} else {
|
||||
throw new FileNotFoundError(cssFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan for all files matching the globs
|
||||
const allFilePaths: string[] = [];
|
||||
|
||||
for (const thisGlob of fileGlobs) {
|
||||
|
@ -104,31 +129,110 @@ export async function loadFromBinding(
|
|||
allFilePaths.push(...results);
|
||||
}
|
||||
|
||||
const bindingFileAbsolutePath = path.resolve(bindingPath);
|
||||
// Exclude the binding.yaml file
|
||||
const filePathsWithoutBindingFile = allFilePaths.filter((filePath) => {
|
||||
return filePath !== bindingFileAbsolutePath;
|
||||
return filePath !== resolvedBindingPath;
|
||||
});
|
||||
|
||||
// -- LOAD ASSOCIATED RESOURCE FILES -- //
|
||||
const loadedResources: UnknownComponent[] = [];
|
||||
// Load discovered component files
|
||||
const componentFiles: ComponentFile[] = [];
|
||||
const fileLoadPromises: Promise<void>[] = [];
|
||||
|
||||
for (const filepath of filePathsWithoutBindingFile) {
|
||||
const loaded = await loadResourceFile(filepath);
|
||||
loadedResources.push(...loaded);
|
||||
const componentFile = new ComponentFile(filepath);
|
||||
componentFiles.push(componentFile);
|
||||
fileLoadPromises.push(componentFile.load());
|
||||
}
|
||||
|
||||
playbill.id = binding.id;
|
||||
await Promise.all(fileLoadPromises);
|
||||
|
||||
// Decorate the playbill with the binding metadata
|
||||
playbill.name = binding.name;
|
||||
playbill.author = binding.author ?? "Anonymous";
|
||||
playbill.version = binding.version;
|
||||
playbill.description = binding.description ?? "";
|
||||
|
||||
for (const resource of loadedResources) {
|
||||
const resourceType = resource.$define;
|
||||
const resourceMap = playbill[resourceType] as Record<
|
||||
string,
|
||||
typeof resource
|
||||
>;
|
||||
resourceMap[resource.id] = resource;
|
||||
const abilities: Ability[] = [];
|
||||
const bluprints: Blueprint[] = [];
|
||||
const glossaries: Record<string, string[]>[] = [];
|
||||
const methods: Method[] = [];
|
||||
const species: Species[] = [];
|
||||
const items: Item[] = [];
|
||||
const rules: Rule[] = [];
|
||||
const resources: Resource[] = [];
|
||||
|
||||
// Aggregate all components before adding to playbill to allow for
|
||||
// ensuring components are added in a valid order
|
||||
for (const file of componentFiles) {
|
||||
// Load components from the file into the playbill
|
||||
for (const component of file.components) {
|
||||
switch (component.type) {
|
||||
case "ability":
|
||||
abilities.push(component.component);
|
||||
break;
|
||||
case "blueprint":
|
||||
bluprints.push(component.component);
|
||||
break;
|
||||
case "glossary":
|
||||
glossaries.push(component.component);
|
||||
break;
|
||||
case "method":
|
||||
methods.push(component.component);
|
||||
break;
|
||||
case "species":
|
||||
species.push(component.component);
|
||||
break;
|
||||
case "item":
|
||||
items.push(component.component);
|
||||
break;
|
||||
case "rule":
|
||||
rules.push(component.component);
|
||||
break;
|
||||
case "resource":
|
||||
resources.push(component.component);
|
||||
break;
|
||||
default:
|
||||
throw new Error("Unknown component type");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add resources first
|
||||
for (const resource of resources) {
|
||||
playbill.addResource(resource);
|
||||
}
|
||||
// Add abilities before methods, species, and blueprints
|
||||
for (const ability of abilities) {
|
||||
playbill.addAbility(ability);
|
||||
}
|
||||
|
||||
// Add species before blueprints
|
||||
for (const specie of species) {
|
||||
playbill.addSpecies(specie);
|
||||
}
|
||||
|
||||
// Add methods after abilities
|
||||
for (const method of methods) {
|
||||
playbill.addMethod(method);
|
||||
}
|
||||
|
||||
// Add items
|
||||
for (const item of items) {
|
||||
playbill.addItem(item);
|
||||
}
|
||||
|
||||
// Add blueprints after methods, species, items, and abilities
|
||||
for (const blueprint of bluprints) {
|
||||
playbill.addBlueprint(blueprint);
|
||||
}
|
||||
|
||||
// Add all definitions
|
||||
for (const glossary of glossaries) {
|
||||
for (const [term, definitions] of Object.entries(glossary)) {
|
||||
for (const definition of definitions) {
|
||||
playbill.addDefinition(term, definition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -143,7 +247,7 @@ export async function loadFromBinding(
|
|||
description: binding.description ?? "",
|
||||
version: binding.version,
|
||||
|
||||
styles: binding.styles,
|
||||
styles: styles.length > 0 ? styles : undefined,
|
||||
|
||||
playbill,
|
||||
};
|
||||
|
|
129
src/lib/component-file.ts
Normal file
129
src/lib/component-file.ts
Normal file
|
@ -0,0 +1,129 @@
|
|||
import path from "node:path";
|
||||
import { type ParsedComponent, parsePlaybillComponent } from "define";
|
||||
import slugify from "slugify";
|
||||
import YAML, { YAMLParseError } from "yaml";
|
||||
import { ZodError } from "zod";
|
||||
import { loadFileOrFail } from "#util";
|
||||
import { MalformedResourceFileError } from "./errors";
|
||||
import { extractFrontmatter, extractMarkdown } from "./markdown";
|
||||
|
||||
type FileFormat = "yaml" | "markdown";
|
||||
|
||||
function parseYAMLResourceFile(
|
||||
filePath: string,
|
||||
text: string,
|
||||
): ParsedComponent[] {
|
||||
const parsedDocs = YAML.parseAllDocuments(text);
|
||||
|
||||
if (parsedDocs.some((doc) => doc.toJS() === null)) {
|
||||
throw new MalformedResourceFileError("Encountered NULL resource", filePath);
|
||||
}
|
||||
|
||||
const errors = parsedDocs.flatMap((doc) => doc.errors);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new MalformedResourceFileError(
|
||||
"Error parsing YAML resource",
|
||||
filePath,
|
||||
errors.map((e) => e.message).join(", "),
|
||||
);
|
||||
}
|
||||
|
||||
const collection: ParsedComponent[] = [];
|
||||
|
||||
for (const doc of parsedDocs) {
|
||||
const raw = doc.toJS();
|
||||
collection.push(parsePlaybillComponent(raw));
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
function parseMarkdownResourceFile(
|
||||
filePath: string,
|
||||
text: string,
|
||||
): ParsedComponent[] {
|
||||
try {
|
||||
const defaultName = path.basename(filePath, ".md");
|
||||
const defaultId = slugify(defaultName, { lower: true });
|
||||
const frontmatter = extractFrontmatter(text);
|
||||
const markdown = extractMarkdown(text);
|
||||
|
||||
const together = parsePlaybillComponent({
|
||||
// Use the file name, allow it to be overridden by frontmatter
|
||||
name: defaultName,
|
||||
id: defaultId,
|
||||
...frontmatter,
|
||||
description: markdown,
|
||||
});
|
||||
|
||||
return [together];
|
||||
} catch (e) {
|
||||
if (e instanceof YAMLParseError) {
|
||||
throw new MalformedResourceFileError(
|
||||
"Error parsing Markdown frontmatter",
|
||||
filePath,
|
||||
e.message,
|
||||
);
|
||||
}
|
||||
|
||||
if (e instanceof ZodError) {
|
||||
throw new MalformedResourceFileError(
|
||||
"Error parsing resource file",
|
||||
filePath,
|
||||
e.message,
|
||||
);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export class ComponentFile {
|
||||
private _filePath: string;
|
||||
private _raw = "";
|
||||
private _format: FileFormat;
|
||||
private _components: ParsedComponent[] = [];
|
||||
|
||||
constructor(filePath: string) {
|
||||
this._filePath = path.resolve(filePath);
|
||||
|
||||
const extension = path.extname(filePath).slice(1).toLowerCase();
|
||||
|
||||
switch (extension) {
|
||||
case "yaml":
|
||||
this._format = "yaml";
|
||||
break;
|
||||
case "md":
|
||||
this._format = "markdown";
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported file format: ${extension}`);
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
this._raw = await loadFileOrFail(this._filePath);
|
||||
switch (this._format) {
|
||||
case "yaml":
|
||||
this._components = parseYAMLResourceFile(this._filePath, this._raw);
|
||||
break;
|
||||
case "markdown":
|
||||
this._components = parseMarkdownResourceFile(this._filePath, this._raw);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An array of well-formed components defined in this file
|
||||
*/
|
||||
get components() {
|
||||
return this._components;
|
||||
}
|
||||
|
||||
/**
|
||||
* The aboslute file path of this file
|
||||
*/
|
||||
get path() {
|
||||
return this._filePath;
|
||||
}
|
||||
}
|
14
src/lib/html.ts
Normal file
14
src/lib/html.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
export function html(
|
||||
strings: TemplateStringsArray,
|
||||
...values: unknown[]
|
||||
): string {
|
||||
const [first, ...rest] = strings;
|
||||
let str = first;
|
||||
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
str += values[i];
|
||||
str += rest[i];
|
||||
}
|
||||
|
||||
return str.trim();
|
||||
}
|
5
src/lib/index.ts
Normal file
5
src/lib/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export * from "./errors";
|
||||
export * from "./markdown";
|
||||
export * from "./component-file";
|
||||
export * from "./binding";
|
||||
export * from "./html";
|
|
@ -1,5 +1,32 @@
|
|||
import type { MarkedExtension } from "marked";
|
||||
import { marked } from "marked";
|
||||
import YAML from "yaml";
|
||||
|
||||
const plugin: MarkedExtension = {
|
||||
renderer: {
|
||||
heading({ tokens, depth }) {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
const level = Math.max(depth, 3);
|
||||
return `<h${level}>${text}</h${level}>`;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
marked.use(plugin);
|
||||
|
||||
/**
|
||||
* Given a string of markdown, convert it to HTML
|
||||
*
|
||||
* @param markdown The markdown to convert
|
||||
* @returns A promise resolving to the HTML representation of the markdown
|
||||
*/
|
||||
export async function renderMarkdown(markdown: string): Promise<string> {
|
||||
// TODO: Custom render tags for links, emphasis, and strong
|
||||
return await marked.parse(markdown, {
|
||||
async: true,
|
||||
gfm: true,
|
||||
});
|
||||
}
|
||||
const FRONTMATTER_REGEX = /^---[\s\S]*?---/gm;
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,118 +0,0 @@
|
|||
import {
|
||||
ComponentMap,
|
||||
type UnknownComponent,
|
||||
UnknownComponentSchema,
|
||||
} from "@proscenium/playbill";
|
||||
import YAML, { YAMLParseError } from "yaml";
|
||||
import { ZodError } from "zod";
|
||||
import { MalformedResourceFileError } from "#lib/errors";
|
||||
import { loadFileOrFail } from "#util/files";
|
||||
import { extractFrontmatter, extractMarkdown } from "./markdown";
|
||||
|
||||
type FileFormat = "yaml" | "markdown";
|
||||
|
||||
function determineFileFormat(filePath: string): FileFormat {
|
||||
return filePath.split(".").pop() === "md" ? "markdown" : "yaml";
|
||||
}
|
||||
|
||||
function loadYamlResourceFile(
|
||||
filePath: string,
|
||||
text: string,
|
||||
): UnknownComponent[] {
|
||||
const parsedDocs = YAML.parseAllDocuments(text);
|
||||
|
||||
if (parsedDocs.some((doc) => doc.toJS() === null)) {
|
||||
throw new MalformedResourceFileError("Encountered NULL resource", filePath);
|
||||
}
|
||||
|
||||
const errors = parsedDocs.flatMap((doc) => doc.errors);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new MalformedResourceFileError(
|
||||
"Error parsing YAML resource",
|
||||
filePath,
|
||||
errors.map((e) => e.message).join(", "),
|
||||
);
|
||||
}
|
||||
|
||||
const collection: UnknownComponent[] = [];
|
||||
|
||||
for (const doc of parsedDocs) {
|
||||
const raw = doc.toJS();
|
||||
const parsed = UnknownComponentSchema.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 = ComponentMap[type];
|
||||
const validated = schemaToUse.parse(parsedResource);
|
||||
collection.push(validated);
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
function loadMarkdownResourceFile(
|
||||
filePath: string,
|
||||
text: string,
|
||||
): UnknownComponent[] {
|
||||
try {
|
||||
const frontmatter = extractFrontmatter(text);
|
||||
const markdown = extractMarkdown(text);
|
||||
|
||||
const together = {
|
||||
...frontmatter,
|
||||
description: markdown,
|
||||
};
|
||||
|
||||
const parsed = UnknownComponentSchema.parse(together);
|
||||
return [parsed];
|
||||
} catch (e) {
|
||||
if (e instanceof YAMLParseError) {
|
||||
throw new MalformedResourceFileError(
|
||||
"Error parsing Markdown frontmatter",
|
||||
filePath,
|
||||
e.message,
|
||||
);
|
||||
}
|
||||
|
||||
if (e instanceof ZodError) {
|
||||
throw new MalformedResourceFileError(
|
||||
"Error parsing resource file",
|
||||
filePath,
|
||||
e.message,
|
||||
);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and process all documents in a yaml file at the given path
|
||||
* @param filePath - The path to the yaml file
|
||||
* @returns An array of validated resources
|
||||
*
|
||||
* @throws ResourceFileError
|
||||
* @throws NullResourceError
|
||||
*/
|
||||
export async function loadResourceFile(
|
||||
filePath: string,
|
||||
): Promise<UnknownComponent[]> {
|
||||
const text = await loadFileOrFail(filePath);
|
||||
const format = determineFileFormat(filePath);
|
||||
|
||||
switch (format) {
|
||||
case "yaml":
|
||||
return loadYamlResourceFile(filePath, text);
|
||||
case "markdown":
|
||||
return loadMarkdownResourceFile(filePath, text);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue