diff --git a/bun.lock b/bun.lock index fa42901..f7c9da3 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,8 @@ "@proscenium/playbill": "link:@proscenium/playbill", "chalk": "^5.4.1", "lodash-es": "^4.17.21", + "marked": "^15.0.7", + "slugify": "^1.6.6", "yaml": "^2.7.0", "zod": "^3.24.1", }, @@ -57,6 +59,10 @@ "lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + "marked": ["marked@15.0.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-dgLIeKGLx5FwziAnsk4ONoGwHwGPJzselimvlVskE9XLN4Orv9u2VA3GWw/lYUqjfA0rUT/6fqKwfZJapP9BEg=="], + + "slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="], + "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], diff --git a/package.json b/package.json index 3597282..cbbb548 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "@proscenium/playbill": "link:@proscenium/playbill", "chalk": "^5.4.1", "lodash-es": "^4.17.21", + "marked": "^15.0.7", + "slugify": "^1.6.6", "yaml": "^2.7.0", "zod": "^3.24.1" }, diff --git a/src/define/index.ts b/src/define/index.ts new file mode 100644 index 0000000..83c3410 --- /dev/null +++ b/src/define/index.ts @@ -0,0 +1,361 @@ +import { + type Ability, + type Blueprint, + type Item, + type Method, + type Resource, + type Rule, + type Species, + parseAbility, + parseBlueprint, + parseGlossary, + parseItem, + parseMethod, + parseResource, + parseRule, + parseSpecies, +} from "@proscenium/playbill"; +import { z } from "zod"; + +const Base = z.object({ + $define: z.string(), +}); + +const Info = Base.extend({ + id: z.string(), // TODO: Validate ID shapes + name: z.string().default("Unnamed Component"), + description: z.string().default("No description provided"), + categories: z.array(z.string()).default([]), +}); + +const AbilitySchema = Info.extend({ + $define: z.literal("ability"), + type: z.string().default("action"), + ap: z.number().int().default(0), + hp: z.number().int().default(0), + ep: z.number().int().default(0), + xp: z.number().int().default(0), + damage: z.number().int().default(0), + damageType: z.string().default("phy"), + roll: z.string().default("none"), +}); + +const BlueprintSchema = Info.extend({ + $define: z.literal("blueprint"), + species: z.string(), + items: z.array(z.string()).default([]), + abilities: z.array(z.string()).default([]), + ap: z.number().int().default(0), + hp: z.number().int().default(0), + ep: z.number().int().default(0), + xp: z.number().int().default(0), + muscle: z.string().default("novice"), + focus: z.string().default("novice"), + knowledge: z.string().default("novice"), + charm: z.string().default("novice"), + cunning: z.string().default("novice"), + spark: z.string().default("novice"), +}); + +const GlossarySchema = Base.extend({ + $define: z.literal("glossary"), + terms: z.record(z.string(), z.string()), +}); + +const BaseItem = Info.extend({ + $define: z.literal("item"), + rarity: z.string().default("common"), + affinity: z.string().default("none"), + damage: z.number().int().default(0), + damageType: z.string().default("phy"), +}); + +const ItemSchema = z.discriminatedUnion("type", [ + BaseItem.extend({ + type: z.literal("wielded"), + hands: z.number().int().default(1), + }), + BaseItem.extend({ + type: z.literal("worn"), + hands: z.number().int().default(1), + }), + BaseItem.extend({ + type: z.literal("trinket"), + }), +]); + +const MethodSchema = Info.extend({ + $define: z.literal("method"), + curator: z.string().default("Someone"), + abilities: z.array(z.array(z.string())).default([]), +}); + +const ResourceSchema = z.discriminatedUnion("type", [ + Info.extend({ + $define: z.literal("resource"), + type: z.literal("text"), + }), + Info.extend({ + $define: z.literal("resource"), + type: z.literal("image"), + url: z.string(), + }), + Info.extend({ + $define: z.literal("resource"), + type: z.literal("table"), + data: z.array(z.array(z.string())), + }), +]); + +const RuleSchema = Info.extend({ + $define: z.literal("rule"), + overrule: z.nullable(z.string()).default(null), + order: z.number().int().default(Number.POSITIVE_INFINITY), +}); + +const SpeciesSchema = Info.extend({ + $define: z.literal("species"), + hands: z.number().int().default(2), + abilities: z.array(z.string()).default([]), + ap: z.number().int().default(0), + hp: z.number().int().default(0), + ep: z.number().int().default(0), + xp: z.number().int().default(0), + muscle: z.string().default("novice"), + focus: z.string().default("novice"), + knowledge: z.string().default("novice"), + charm: z.string().default("novice"), + cunning: z.string().default("novice"), + spark: z.string().default("novice"), +}); + +const SchemaMapping = { + ability: AbilitySchema, + blueprint: BlueprintSchema, + glossary: GlossarySchema, + item: ItemSchema, + method: MethodSchema, + resource: ResourceSchema, + rule: RuleSchema, + species: SpeciesSchema, +} as const; + +type ComponentType = keyof typeof SchemaMapping; + +const parseComponentType = (val: unknown): ComponentType => { + if (typeof val !== "string") { + throw new Error("Component type must be a string"); + } + + if (!(val in SchemaMapping)) { + throw new Error(`Unknown component type: ${val}`); + } + + return val as ComponentType; +}; + +export type ParsedComponent = + | { + type: "ability"; + component: Ability; + } + | { + type: "blueprint"; + component: Blueprint; + } + | { + type: "glossary"; + component: Record; + } + | { + type: "item"; + component: Item; + } + | { + type: "method"; + component: Method; + } + | { + type: "resource"; + component: Resource; + } + | { + type: "rule"; + component: Rule; + } + | { + type: "species"; + component: Species; + }; + +function parseAbilityDefinition(obj: unknown): ParsedComponent { + const parsed = AbilitySchema.parse(obj); + + const ability = parseAbility({ + id: parsed.id, + name: parsed.name, + description: parsed.description, + categories: parsed.categories, + type: parsed.type, + ap: parsed.ap, + ep: parsed.ep, + hp: parsed.hp, + xp: parsed.xp, + damage: + parsed.damage > 0 + ? { + amount: parsed.damage, + type: parsed.damageType, + } + : null, + roll: parsed.roll, + boons: [], + banes: [], + }); + + return { + type: "ability", + component: ability, + }; +} + +function parseBlueprintDefinition(obj: unknown): ParsedComponent { + const parsed = BlueprintSchema.parse(obj); + + const blueprint = parseBlueprint({ + id: parsed.id, + name: parsed.name, + description: parsed.description, + categories: parsed.categories, + species: parsed.species, + items: parsed.items, + abilities: parsed.abilities, + baggage: [], + ap: parsed.ap, + hp: parsed.hp, + ep: parsed.ep, + xp: parsed.xp, + muscle: parsed.muscle, + focus: parsed.focus, + knowledge: parsed.knowledge, + charm: parsed.charm, + cunning: parsed.cunning, + spark: parsed.spark, + }); + + return { + type: "blueprint", + component: blueprint, + }; +} + +function parseGlossaryDefinition(obj: unknown): ParsedComponent { + const { terms } = GlossarySchema.parse(obj); + + // Create a new object with each definition as an array + const curatedTerms: Record = {}; + for (const [term, definition] of Object.entries(terms)) { + curatedTerms[term] = [definition]; + } + + const glossary = parseGlossary(curatedTerms); + + return { + type: "glossary", + component: glossary, + }; +} + +function parseItemDefinition(obj: unknown): ParsedComponent { + const parsed = ItemSchema.parse(obj); + + return { + type: "item", + component: parseItem({ + ...parsed, + damage: + parsed.damage > 0 + ? { + amount: parsed.damage, + type: parsed.damageType, + } + : null, + tweak: "", + temper: "", + }), + }; +} + +function parseMethodDefinition(obj: unknown): ParsedComponent { + const parsed = MethodSchema.parse(obj); + + const method = { + id: parsed.id, + name: parsed.name, + description: parsed.description, + categories: parsed.categories, + curator: parsed.curator, + abilities: parsed.abilities, + }; + + return { + type: "method", + component: parseMethod(method), + }; +} + +function parseResourceDefinition(obj: unknown): ParsedComponent { + const parsed = ResourceSchema.parse(obj); + + return { + type: "resource", + component: parseResource(parsed), + }; +} + +function parseRuleDefinition(obj: unknown): ParsedComponent { + const parsed = RuleSchema.parse(obj); + + return { + type: "rule", + component: parseRule(parsed), + }; +} + +function parseSpeciesDefinition(obj: unknown): ParsedComponent { + const parsed = SpeciesSchema.parse(obj); + + return { + type: "species", + component: parseSpecies(parsed), + }; +} + +export function parsePlaybillComponent(obj: unknown): ParsedComponent { + const baseParse = Base.parse(obj); + + const type = parseComponentType(baseParse.$define); + const schema = SchemaMapping[type]; + const component = schema.parse(obj); + + switch (type) { + case "ability": + return parseAbilityDefinition(component); + case "blueprint": + return parseBlueprintDefinition(component); + case "glossary": + return parseGlossaryDefinition(component); + case "item": + return parseItemDefinition(component); + case "method": + return parseMethodDefinition(component); + case "resource": + return parseResourceDefinition(component); + case "rule": + return parseRuleDefinition(component); + case "species": + return parseSpeciesDefinition(component); + default: + throw new Error(`Unknown component type: ${type}`); + } +} diff --git a/src/index.ts b/src/index.ts index cd7a6d1..befddd4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,13 @@ import { watch } from "node:fs/promises"; -import { - PlaybillSchema, - renderPlaybillToHTML, - renderPlaybillToJSON, -} from "@proscenium/playbill"; import chalk from "chalk"; -import { loadFromBinding, resolveBindingPath } from "#lib/binding"; -import { CLIError, FileNotFoundError, MuseError } from "#lib/errors"; -import { type CLIArguments, parseCLIArguments } from "#util/args"; -import { getAbsoluteDirname } from "#util/files"; +import { CLIError, MuseError, loadFromBinding, resolveBindingPath } from "#lib"; +import { renderPlaybillToHTML } from "#render/html"; +import { + type CLIArguments, + getAbsoluteDirname, + parseCLIArguments, +} from "#util"; import { version } from "../package.json" with { type: "json" }; -import path from "node:path"; const usage = ` ${chalk.bold("muse")} - Compile and validate Playbills for Proscenium @@ -39,47 +36,22 @@ async function processBinding({ inputFilePath, options }: CLIArguments) { const bindingPath = await resolveBindingPath(inputFilePath); const binding = await loadFromBinding(bindingPath); - // -- VALDATE PLAYBILL -- // - const validatedPlaybill = PlaybillSchema.safeParse(binding.playbill); - - if (!validatedPlaybill.success) { - console.error("Error validating playbill"); - console.error(validatedPlaybill.error.errors); - return ExitCode.Error; - } - // -- EXIT EARLY IF JUST CHECKING VALIDATION --// if (options.check) { console.log(chalk.green("Playbill validated successfully")); return ExitCode.Success; } - let css = ""; - - if (binding.styles && binding.styles.length > 0) { - for (const style of binding.styles) { - const cssFilePath = path.resolve(binding.bindingFileDirname, style); - const cssFile = Bun.file(cssFilePath); - const cssFileExists = await cssFile.exists(); - - if (cssFileExists) { - css += `${await cssFile.text()}\n`; - } else { - throw new FileNotFoundError(cssFilePath); - } - } - } - // -- SERIALIZE USING RENDERER --// let serializedPlaybill = ""; switch (options.renderer) { case "json": - serializedPlaybill = await renderPlaybillToJSON(validatedPlaybill.data); + serializedPlaybill = binding.playbill.serialize(); break; case "html": - serializedPlaybill = await renderPlaybillToHTML(validatedPlaybill.data, { - styles: css, + serializedPlaybill = await renderPlaybillToHTML(binding.playbill, { + styles: binding.styles, }); break; default: diff --git a/src/lib/binding.ts b/src/lib/binding.ts index 42fc53b..42d75c6 100644 --- a/src/lib/binding.ts +++ b/src/lib/binding.ts @@ -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; +type Binding = z.infer; -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 { // 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 { export async function loadFromBinding( bindingPath: string, -): Promise { +): Promise { 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[] = []; + 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[] = []; + 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, }; diff --git a/src/lib/component-file.ts b/src/lib/component-file.ts new file mode 100644 index 0000000..3a903bd --- /dev/null +++ b/src/lib/component-file.ts @@ -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; + } +} diff --git a/src/lib/html.ts b/src/lib/html.ts new file mode 100644 index 0000000..90c42ce --- /dev/null +++ b/src/lib/html.ts @@ -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(); +} diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..957897f --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1,5 @@ +export * from "./errors"; +export * from "./markdown"; +export * from "./component-file"; +export * from "./binding"; +export * from "./html"; diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index e5d7905..4daaeb6 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -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 `${text}`; + }, + }, +}; + +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 { + // TODO: Custom render tags for links, emphasis, and strong + return await marked.parse(markdown, { + async: true, + gfm: true, + }); +} const FRONTMATTER_REGEX = /^---[\s\S]*?---/gm; /** diff --git a/src/lib/resource.ts b/src/lib/resource.ts deleted file mode 100644 index b0c97d1..0000000 --- a/src/lib/resource.ts +++ /dev/null @@ -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 { - const text = await loadFileOrFail(filePath); - const format = determineFileFormat(filePath); - - switch (format) { - case "yaml": - return loadYamlResourceFile(filePath, text); - case "markdown": - return loadMarkdownResourceFile(filePath, text); - } -} diff --git a/src/render/html/component/ability.ts b/src/render/html/component/ability.ts new file mode 100644 index 0000000..8b090a6 --- /dev/null +++ b/src/render/html/component/ability.ts @@ -0,0 +1,41 @@ +import type { Ability } from "@proscenium/playbill"; +import { html, renderMarkdown } from "#lib"; + +export async function AbilityCard(ability: Ability): Promise { + const renderedMarkdown = await renderMarkdown(ability.description); + + const costs: string[] = []; + + if (ability.ap > 0) { + costs.push(`
  • ${ability.ap} AP
  • `); + } + + if (ability.hp > 0) { + costs.push(`
  • ${ability.hp} HP
  • `); + } + + if (ability.ep > 0) { + costs.push(`
  • ${ability.ep} EP
  • `); + } + + const costList = + costs.length > 0 + ? `
      ${costs.join("\n")}
    ` + : ""; + + const classList = `ability ability-${ability.type}`; + + return html` +
    +
    +

    ${ability.name}

    + ${ability.type} + ${ability.xp} XP + ${costList} +
    +
    + ${renderedMarkdown} +
    +
    + `; +} diff --git a/src/render/html/component/base/section.ts b/src/render/html/component/base/section.ts new file mode 100644 index 0000000..6366661 --- /dev/null +++ b/src/render/html/component/base/section.ts @@ -0,0 +1,39 @@ +import { html } from "#lib"; + +interface SectionProps { + type: string; + title: string; + content: string; + preInfo?: string; + info?: string; + componentId?: string; + leftCornerTag?: string; + rightCornerTag?: string; +} + +export async function Section({ + type, + title, + content, + preInfo = "", + info = "", + componentId, + leftCornerTag = "", + rightCornerTag = "", +}: SectionProps): Promise { + const dataTags = componentId ? `data-component-id="${componentId}"` : ""; + return html` +
    +
    + ${preInfo} +

    ${title}

    + ${info} +
    + + +
    + ${content} +
    +
    + `; +} diff --git a/src/render/html/component/glossary.ts b/src/render/html/component/glossary.ts new file mode 100644 index 0000000..731e53c --- /dev/null +++ b/src/render/html/component/glossary.ts @@ -0,0 +1,25 @@ +import { html } from "#lib"; +import { Section } from "./base/section"; + +export async function GlossaryTable( + glossary: Record, +): Promise { + const terms = Object.entries(glossary).map(([term, defs]) => { + return html`
    ${term}
    ${defs.map((d) => html`
    ${d}
    `).join("\n")}`; + }); + + const content = html` +
    +
    + ${terms.join("\n")} +
    +
    + `; + + return Section({ + type: "glossary", + componentId: "glossary", + title: "Glossary", + content, + }); +} diff --git a/src/render/html/component/header.ts b/src/render/html/component/header.ts new file mode 100644 index 0000000..f953d28 --- /dev/null +++ b/src/render/html/component/header.ts @@ -0,0 +1,12 @@ +import type { Playbill } from "@proscenium/playbill"; +import { html } from "#lib"; + +export async function PageHeader(playbill: Playbill): Promise { + return html` +
    +

    ${playbill.name}

    + +

    Version ${playbill.version}

    +
    + `; +} diff --git a/src/render/html/component/item.ts b/src/render/html/component/item.ts new file mode 100644 index 0000000..4c08f09 --- /dev/null +++ b/src/render/html/component/item.ts @@ -0,0 +1,51 @@ +import type { Item } from "@proscenium/playbill"; +import { capitalize } from "lodash-es"; +import { html, renderMarkdown } from "#lib"; +import { Section } from "./base/section"; + +export async function ItemCard(item: Item): Promise { + const renderedMarkdown = await renderMarkdown(item.description); + + let itemTypeDescriptor = ""; + + switch (item.type) { + case "wielded": + itemTypeDescriptor = `Wielded (${item.hands} ${item.hands === 1 ? "Hand" : "Hands"})`; + break; + case "worn": + itemTypeDescriptor = `Worn (${item.slot})`; + break; + case "trinket": + itemTypeDescriptor = "Trinket"; + break; + } + + const affinity = + item.affinity !== "none" + ? html`

    ${item.affinity} Affinity

    ` + : ""; + + const damageType = item.damage?.type === "arc" ? "Arcane" : "Physical"; + const damageAmount = item.damage?.amount ?? 0; + const damageRating = + damageAmount > 0 + ? html`

    ${damageAmount} ${damageType} Damage

    ` + : ""; + + const info = html` +
    + ${affinity} + ${damageRating} +
    + `; + + return Section({ + type: "item", + componentId: item.id, + title: item.name, + content: renderedMarkdown, + info, + rightCornerTag: capitalize(item.rarity), + leftCornerTag: itemTypeDescriptor, + }); +} diff --git a/src/render/html/component/method.ts b/src/render/html/component/method.ts new file mode 100644 index 0000000..62d9ed9 --- /dev/null +++ b/src/render/html/component/method.ts @@ -0,0 +1,38 @@ +import type { Method, Playbill } from "@proscenium/playbill"; +import { html, renderMarkdown } from "#lib"; +import { AbilityCard } from "./ability"; +import { Section } from "./base/section"; + +export async function MethodSection(method: Method, playbill: Playbill) { + const descriptionHTML = await renderMarkdown(method.description); + let ranksHTML = ""; + let rankNumber = 1; + + for (const rank of method.abilities) { + ranksHTML += `

    Rank ${rankNumber}

    `; + + for (const ability of rank) { + const html = await AbilityCard(playbill.abilities[ability]); + ranksHTML += html; + } + + ranksHTML += "
    "; + + rankNumber++; + } + + const methodContent = html` +
    + ${ranksHTML} +
    + `; + + return Section({ + type: "method", + componentId: method.id, + title: method.name, + preInfo: html`

    ${method.curator}'s Method of

    `, + info: html`

    ${descriptionHTML}

    `, + content: methodContent, + }); +} diff --git a/src/render/html/component/resource.ts b/src/render/html/component/resource.ts new file mode 100644 index 0000000..73fdce0 --- /dev/null +++ b/src/render/html/component/resource.ts @@ -0,0 +1,102 @@ +import type { + ImageResource, + Resource, + TableResource, + TextResource, +} from "@proscenium/playbill"; +import { html, renderMarkdown } from "#lib"; +import { Section } from "./base/section"; + +async function TextResourceSection(resource: TextResource): Promise { + const renderedMarkdown = await renderMarkdown(resource.description); + + const info = resource.categories + .map((category) => { + return html`
  • ${category}
  • `; + }) + .join("\n"); + + return Section({ + type: "resource", + componentId: resource.id, + title: resource.name, + content: renderedMarkdown, + info: `
      ${info}
    `, + }); +} + +async function ImageResourceSection(resource: ImageResource): Promise { + const renderedMarkdown = await renderMarkdown(resource.description); + + const info = resource.categories + .map((category) => { + return html`
  • ${category}
  • `; + }) + .join("\n"); + + const fig = html` +
    + ${resource.name} +
    ${renderedMarkdown}
    +
    + `; + + return Section({ + type: "resource", + componentId: resource.id, + title: resource.name, + content: fig, + info: `
      ${info}
    `, + }); +} + +async function TableResourceSection(resource: TableResource): Promise { + const renderedMarkdown = await renderMarkdown(resource.description); + + const info = resource.categories + .map((category) => { + return html`
  • ${category}
  • `; + }) + .join("\n"); + + const tableData = resource.data; + + const [headerRow, ...rows] = tableData; + + let tableHTML = html``; + + for (const header of headerRow) { + tableHTML += html``; + } + + tableHTML += html``; + + for (const row of rows) { + tableHTML += html``; + for (const cell of row) { + tableHTML += html``; + } + tableHTML += html``; + } + + tableHTML += html`
    ${await renderMarkdown(header)}
    ${await renderMarkdown(cell)}
    `; + + return Section({ + type: "resource", + componentId: resource.id, + title: resource.name, + content: renderedMarkdown + tableHTML, + info: `
      ${info}
    `, + }); +} + +export async function ResourceSection(resource: Resource): Promise { + switch (resource.type) { + case "text": + return TextResourceSection(resource); + case "image": + return ImageResourceSection(resource); + case "table": + return TableResourceSection(resource); + } +} diff --git a/src/render/html/component/rule.ts b/src/render/html/component/rule.ts new file mode 100644 index 0000000..5f3a2be --- /dev/null +++ b/src/render/html/component/rule.ts @@ -0,0 +1,14 @@ +import type { Rule } from "@proscenium/playbill"; +import { renderMarkdown } from "#lib"; +import { Section } from "./base/section"; + +export async function RuleSection(rule: Rule): Promise { + const renderedMarkdown = await renderMarkdown(rule.description); + + return Section({ + title: rule.name, + componentId: rule.id, + content: renderedMarkdown, + type: "rule", + }); +} diff --git a/src/render/html/component/species.ts b/src/render/html/component/species.ts new file mode 100644 index 0000000..efd8de7 --- /dev/null +++ b/src/render/html/component/species.ts @@ -0,0 +1,97 @@ +import type { Playbill, Species } from "@proscenium/playbill"; +import { html, renderMarkdown } from "#lib"; +import { AbilityCard } from "./ability"; +import { Section } from "./base/section"; + +export async function SpeciesSection(species: Species, playbill: Playbill) { + const descriptionHTML = await renderMarkdown(species.description); + let abilitiesHTML = ""; + + for (const ability of species.abilities) { + abilitiesHTML += await AbilityCard(playbill.abilities[ability]); + } + + const hpString = species.hp >= 0 ? `+${species.hp}` : `-${species.hp}`; + const apString = species.ap >= 0 ? `+${species.ap}` : `-${species.ap}`; + const epString = species.ep >= 0 ? `+${species.ep}` : `-${species.ep}`; + + const hasAbilities = species.abilities.length > 0; + + const abilitySection = hasAbilities + ? html` +
    +

    Innate Abilities

    + ${abilitiesHTML} +
    + ` + : ""; + + const sectionContent = html` +
    + +
    +

    HP

    +

    ${hpString}

    +
    + +
    +

    AP

    +

    ${apString}

    +
    + +
    +

    EP

    +

    ${epString}

    +
    + +
    + +
    + +
    +

    Muscle

    +

    ${species.muscle}

    +
    + +
    +

    Focus

    +

    ${species.focus}

    +
    + +
    +

    Knowledge

    +

    ${species.knowledge}

    +
    + +
    +

    Charm

    +

    ${species.charm}

    +
    + +
    +

    Cunning

    +

    ${species.cunning}

    +
    + +
    +

    Spark

    +

    ${species.spark}

    +
    + +
    +
    + ${descriptionHTML} +
    + + + ${abilitySection} + `; + + return Section({ + type: "species", + componentId: species.id, + title: species.name, + content: sectionContent, + info: "

    Species

    ", + }); +} diff --git a/src/render/html/index.ts b/src/render/html/index.ts new file mode 100644 index 0000000..927f363 --- /dev/null +++ b/src/render/html/index.ts @@ -0,0 +1,164 @@ +import type { Playbill } from "@proscenium/playbill"; +import sortBy from "lodash-es/sortBy"; +import { html } from "#lib"; +import { GlossaryTable } from "./component/glossary"; +import { PageHeader } from "./component/header"; +import { ItemCard } from "./component/item"; +import { MethodSection } from "./component/method"; +import { ResourceSection } from "./component/resource"; +import { RuleSection } from "./component/rule"; +import { SpeciesSection } from "./component/species"; + +interface HTMLRenderOptions { + styles?: string; +} + +export async function renderPlaybillToHTML( + playbill: Playbill, + opts: HTMLRenderOptions = {}, +): Promise { + // Render various resources + const resources = ( + await Promise.all(Object.values(playbill.resources).map(ResourceSection)) + ).join("\n"); + + const methods = ( + await Promise.all( + Object.values(playbill.methods).map((method) => { + return MethodSection(method, playbill); + }), + ) + ).join("\n"); + + const species = ( + await Promise.all( + Object.values(playbill.species).map((species) => { + return SpeciesSection(species, playbill); + }), + ) + ).join("\n"); + + const items = ( + await Promise.all( + Object.values(playbill.items).map((item) => { + return ItemCard(item); + }), + ) + ).join("\n"); + + const rulesByOrder = sortBy(Object.values(playbill.rules), "order"); + const rules = (await Promise.all(rulesByOrder.map(RuleSection))).join("\n"); + const glossaryHTML = await GlossaryTable(playbill.glossary); + + // Prepare stylesheet + const css = opts.styles ? html`` : ""; + + // Main document + const doc = html` + + + + + + Playbill: ${playbill.name} + ${css} + + + + + ${await PageHeader(playbill)} +

    ${playbill.description}

    + +
    + ${species} +
    + +
    + ${methods} +
    + +
    + ${items} +
    + +
    + ${rules} +
    + +
    + ${glossaryHTML} +
    + +
    + ${resources} +
    + + + + + `; + + return doc; +} diff --git a/src/util/args.ts b/src/util/args.ts index 1ea64c5..2f75b4c 100644 --- a/src/util/args.ts +++ b/src/util/args.ts @@ -1,6 +1,6 @@ import { parseArgs } from "node:util"; import { z } from "zod"; -import { CLIError, MuseError } from "#lib/errors"; +import { CLIError, MuseError } from "#lib"; const Renderers = ["json", "html"] as const; const RendererSchema = z.enum(Renderers); diff --git a/src/util/index.ts b/src/util/index.ts new file mode 100644 index 0000000..1d10193 --- /dev/null +++ b/src/util/index.ts @@ -0,0 +1,2 @@ +export * from "./args"; +export * from "./files"; diff --git a/tsconfig.json b/tsconfig.json index e2db0a1..429f093 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,14 +25,14 @@ "noPropertyAccessFromIndexSignature": false, "baseUrl": "./src", "paths": { - "#lib/*": [ - "./lib/*" + "#lib": [ + "./lib/index.ts" ], - "#renderers/*": [ - "./renderers/*" + "#render/*": [ + "./render/*" ], - "#util/*": [ - "./util/*" + "#util": [ + "./util/index.ts" ], } }