Generic muse

This commit is contained in:
Endeavorance 2025-04-01 15:43:48 -04:00
parent c66dd4d39c
commit c1166680a8
31 changed files with 412 additions and 2221 deletions

1
.gitignore vendored
View file

@ -176,3 +176,4 @@ dist
demo-data
demo.html
demo

View file

@ -20,6 +20,7 @@
"remark-rehype": "^11.1.1",
"remark-wiki-link": "^2.0.1",
"slugify": "^1.6.6",
"smol-toml": "^1.3.1",
"unified": "^11.0.5",
"yaml": "^2.7.0",
"zod": "^3.24.1",
@ -283,6 +284,8 @@
"slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="],
"smol-toml": ["smol-toml@1.3.1", "", {}, "sha512-tEYNll18pPKHroYSmLLrksq233j021G0giwW7P3D24jC54pQ5W5BXMsQ/Mvw1OJCmEYDgY+lrzT+3nNUtoNfXQ=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],

View file

@ -30,6 +30,7 @@
"remark-rehype": "^11.1.1",
"remark-wiki-link": "^2.0.1",
"slugify": "^1.6.6",
"smol-toml": "^1.3.1",
"unified": "^11.0.5",
"yaml": "^2.7.0",
"zod": "^3.24.1"

56
src/args.ts Normal file
View file

@ -0,0 +1,56 @@
import { parseArgs } from "node:util";
import chalk from "chalk";
import { version } from "../package.json" with { type: "json" };
export const USAGE = `
${chalk.bold("muse")} - Compile and process troves of data
${chalk.dim(`v${version}`)}
Usage:
muse [/path/to/binding.yaml] <options>
Options:
--help, -h Show this help message
`.trim();
/**
* A shape representing the arguments passed to the CLI
*/
export interface CLIArguments {
inputFilePath: string;
options: {
help: boolean;
};
}
/**
* Given an array of CLI arguments, parse them into a structured object
*
* @param argv The arguments to parse
* @returns The parsed CLI arguments
*
* @throws {CLIError} if the arguments are invalid
*/
export function parseCLIArguments(argv: string[]): CLIArguments {
const { values: options, positionals: args } = parseArgs({
args: argv,
options: {
help: {
short: "h",
default: false,
type: "boolean",
},
},
strict: true,
allowPositionals: true,
});
return {
inputFilePath: args[0] ?? "./binding.yaml",
options: {
help: options.help,
},
};
}

164
src/binding.ts Normal file
View file

@ -0,0 +1,164 @@
import path from "node:path";
import { Glob } from "bun";
import z from "zod";
import { FileNotFoundError, MuseError } from "./errors";
import { MuseFile, type MuseFileContent } from "./muse-file";
// binding.yaml file schema
const BindingSchema = z.object({
include: z.array(z.string()).default([]),
name: z.string().default("Unnamed playbill"),
author: z.string().default("Someone"),
description: z.string().default("No description provided"),
version: z.string().default("0.0.0"),
files: z
.array(z.string())
.default(["**/*.yaml", "**/*.md", "**/*.toml", "**/*.json"]),
options: z.record(z.string(), z.any()).default({}),
processors: z.array(z.string()).default([]),
});
type ParsedBinding = z.infer<typeof BindingSchema>;
/**
* 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);
// If it is a directory, try again seeking a binding.yaml inside
const stat = await inputLocation.stat();
if (stat.isDirectory()) {
return resolveBindingPath(path.resolve(bindingPath, "binding.yaml"));
}
// If it doesnt exist, bail
if (!(await inputLocation.exists())) {
throw new FileNotFoundError(bindingPath);
}
// Resolve the path
return path.resolve(bindingPath);
}
export interface BindingMetadata {
name: string;
author: string;
description: string;
version: string;
}
export interface MuseProcessor {
readonly filePath: string;
process(binding: Binding): Promise<Binding>;
}
export interface Binding {
readonly _raw: ParsedBinding;
readonly bindingPath: string;
readonly includedFiles: string[];
readonly entries: MuseFileContent[];
readonly metadata: BindingMetadata;
readonly options: Record<string, unknown>;
readonly processors: MuseProcessor[];
readonly imports: Binding[];
}
export async function loadBinding(initialPath: string): Promise<Binding> {
// Resolve the file location if possible
const bindingPath = await resolveBindingPath(initialPath);
const bindingFile = new MuseFile(bindingPath);
// Load and parse the Binding YAML content
const bindingFileContent = await bindingFile.read();
const parsedBinding = BindingSchema.parse(bindingFileContent.data);
// Prepare collections to load into
const extendsFrom: Binding[] = [];
const entries: MuseFileContent[] = [];
// Process imported bindings and bring in their entries
for (const includePath of parsedBinding.include) {
const pathFromHere = path.resolve(bindingFile.dirname, includePath);
const extendBindingPath = await resolveBindingPath(pathFromHere);
const loadedBinding = await loadBinding(extendBindingPath);
extendsFrom.push(loadedBinding);
entries.push(...loadedBinding.entries);
}
// Collect file manifest from globs
const allFilePaths: string[] = [];
for (const thisGlob of parsedBinding.files) {
const glob = new Glob(thisGlob);
const results = glob.scanSync({
cwd: bindingFile.dirname,
absolute: true,
followSymlinks: true,
onlyFiles: true,
});
allFilePaths.push(...results);
}
// Exclude the binding.yaml file
const includedFilePaths = allFilePaths.filter((filePath) => {
return filePath !== bindingFile.path;
});
const fileLoadPromises: Promise<MuseFileContent>[] = [];
for (const filePath of includedFilePaths) {
const file = new MuseFile(filePath);
fileLoadPromises.push(file.read());
}
const mainEntries = await Promise.all(fileLoadPromises);
entries.push(...mainEntries);
// Load and check processors
const processors: MuseProcessor[] = [];
for (const processorPath of parsedBinding.processors) {
const resolvedProcessorPath = path.resolve(
bindingFile.dirname,
processorPath,
);
const { process } = await import(resolvedProcessorPath);
if (!process || typeof process !== "function") {
throw new MuseError(
`Processor at ${processorPath} does not export a process() function`,
);
}
processors.push({
filePath: resolvedProcessorPath,
process: process as MuseProcessor["process"],
});
}
return {
_raw: parsedBinding,
bindingPath,
includedFiles: includedFilePaths,
entries,
options: parsedBinding.options,
processors: processors,
imports: extendsFrom,
metadata: {
name: parsedBinding.name,
author: parsedBinding.author,
description: parsedBinding.description,
version: parsedBinding.version,
},
};
}

2
src/css.d.ts vendored
View file

@ -1,2 +0,0 @@
declare module '*.css' {
}

View file

@ -1,326 +0,0 @@
import {
type Ability,
type AnyPlaybillComponent,
type Blueprint,
type Item,
type Method,
type Resource,
type Rule,
type Species,
parseAbility,
parseBlueprint,
parseItem,
parseMethod,
parseResource,
parseRule,
parseSpecies,
} from "@proscenium/playbill";
import { z } from "zod";
const Base = z.object({
$define: z.string(),
$hidden: z.boolean().default(false),
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 = Base.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(1),
damage: z.number().int().default(0),
damageType: z.string().default("phy"),
roll: z.string().default("none"),
});
const BlueprintSchema = Base.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(4),
hp: z.number().int().default(5),
ep: z.number().int().default(1),
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 = Base.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"),
abilities: z.array(z.string()).default([]),
});
const ItemSchema = z.discriminatedUnion("type", [
BaseItem.extend({
type: z.literal("wielded"),
hands: z.number().int().default(1),
}),
BaseItem.extend({
type: z.literal("worn"),
slot: z.string().default("unique"),
}),
BaseItem.extend({
type: z.literal("trinket"),
}),
]);
const MethodSchema = Base.extend({
$define: z.literal("method"),
curator: z.string().default("Someone"),
abilities: z.array(z.array(z.string())).default([]),
rank1: z.array(z.string()).default([]),
rank2: z.array(z.string()).default([]),
rank3: z.array(z.string()).default([]),
rank4: z.array(z.string()).default([]),
rank5: z.array(z.string()).default([]),
rank6: z.array(z.string()).default([]),
rank7: z.array(z.string()).default([]),
rank8: z.array(z.string()).default([]),
rank9: z.array(z.string()).default([]),
});
const ResourceSchema = z.discriminatedUnion("type", [
Base.extend({
$define: z.literal("resource"),
type: z.literal("text"),
}),
Base.extend({
$define: z.literal("resource"),
type: z.literal("image"),
url: z.string(),
}),
Base.extend({
$define: z.literal("resource"),
type: z.literal("table"),
data: z.array(z.array(z.string())),
}),
]);
const RuleSchema = Base.extend({
$define: z.literal("rule"),
overrule: z.nullable(z.string()).default(null),
order: z.number().int().default(Number.POSITIVE_INFINITY),
});
const SpeciesSchema = Base.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;
};
function parseAbilityDefinition(obj: unknown): Ability {
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,
});
return ability;
}
function parseBlueprintDefinition(obj: unknown): Blueprint {
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 blueprint;
}
function parseItemDefinition(obj: unknown): Item {
const parsed = ItemSchema.parse(obj);
return parseItem({
...parsed,
damage:
parsed.damage > 0
? {
amount: parsed.damage,
type: parsed.damageType,
}
: null,
abilities: parsed.abilities,
tweak: "",
temper: "",
});
}
function parseMethodDefinition(obj: unknown): Method {
const parsed = MethodSchema.parse(obj);
// Prefer `abilities` if defined, otherwise
// construct `abilities` using the rankX properties
const abilities = parsed.abilities;
if (abilities.length === 0) {
const allRanks = [
parsed.rank1,
parsed.rank2,
parsed.rank3,
parsed.rank4,
parsed.rank5,
parsed.rank6,
parsed.rank7,
parsed.rank8,
parsed.rank9,
];
// Add all ranks until an empty rank is found
for (const rank of allRanks) {
if (rank.length > 0) {
abilities.push(rank);
} else {
break;
}
}
}
const method = {
id: parsed.id,
name: parsed.name,
description: parsed.description,
categories: parsed.categories,
curator: parsed.curator,
abilities: abilities,
};
return parseMethod(method);
}
function parseResourceDefinition(obj: unknown): Resource {
const parsed = ResourceSchema.parse(obj);
return parseResource(parsed);
}
function parseRuleDefinition(obj: unknown): Rule {
const parsed = RuleSchema.parse(obj);
return parseRule(parsed);
}
function parseSpeciesDefinition(obj: unknown): Species {
const parsed = SpeciesSchema.parse(obj);
return parseSpecies(parsed);
}
export function parsePlaybillComponent(
obj: unknown,
): AnyPlaybillComponent | null {
const baseParse = Base.parse(obj);
if (baseParse.$hidden) {
return null;
}
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 "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}`);
}
}

View file

@ -1,62 +1,29 @@
import { watch } from "node:fs/promises";
import chalk from "chalk";
import {
CLIError,
MuseError,
compileMarkdownInPlaybill,
loadFromBinding,
resolveBindingPath,
} from "#lib";
import { renderPlaybillToHTML } from "#render/html";
import {
type CLIArguments,
getAbsoluteDirname,
parseCLIArguments,
USAGE,
} from "#util";
import { MuseError } from "./errors";
import { loadBinding, type Binding } from "./binding";
import { type CLIArguments, parseCLIArguments, USAGE } from "./args";
enum ExitCode {
Success = 0,
Error = 1,
}
async function processBinding({ inputFilePath, options }: CLIArguments) {
async function processBinding({ inputFilePath }: CLIArguments) {
// Load the binding
const bindingPath = await resolveBindingPath(inputFilePath);
const binding = await loadFromBinding(bindingPath);
const binding = await loadBinding(inputFilePath);
// If --check is specified, exit early
if (options.check) {
console.log(chalk.green("Playbill validated successfully"));
return ExitCode.Success;
// Run the data through all processors
const processedSteps: Binding[] = [binding];
for (const processor of binding.processors) {
const processedStep = await processor.process(binding);
processedSteps.push(processedStep);
}
if (options.markdown) {
await compileMarkdownInPlaybill(binding);
}
const finalState = processedSteps[processedSteps.length - 1];
const serialized = JSON.stringify(finalState.entries, null, 2);
// Serialize (default: JSON)
let serializedPlaybill = "";
switch (options.renderer) {
case "json":
serializedPlaybill = binding.playbill.serialize();
break;
case "html":
serializedPlaybill = await renderPlaybillToHTML(binding);
break;
default:
throw new CLIError(`Unknown renderer: ${options.renderer}`);
}
// Write to disk if --outfile is specified
if (options.outfile !== "") {
await Bun.write(options.outfile, serializedPlaybill);
return ExitCode.Success;
}
// Otherwise, write to stdout
console.log(serializedPlaybill);
// Otherwise
console.log(serialized);
return ExitCode.Success;
}
@ -70,39 +37,9 @@ async function main(): Promise<number> {
return ExitCode.Success;
}
let lastProcessResult = await processBinding(cliArguments);
const lastProcessResult = await processBinding(cliArguments);
if (options.watch) {
const watchDir = getAbsoluteDirname(cliArguments.inputFilePath);
console.log(`Watching ${watchDir} for changes...`);
const watcher = watch(watchDir, {
recursive: true,
});
for await (const event of watcher) {
console.log(
`Detected ${event.eventType} on ${event.filename}. Reprocessing...`,
);
try {
lastProcessResult = await processBinding(cliArguments);
if (lastProcessResult === ExitCode.Error) {
console.error(`Error processing ${event.filename}`);
} else {
console.log("Reprocessed changes");
}
} catch (error) {
console.error(`Error processing ${event.filename}`);
console.error(error);
}
}
return ExitCode.Success;
}
return ExitCode.Success;
return lastProcessResult;
}
try {

View file

@ -1,177 +0,0 @@
import path from "node:path";
import { type AnyPlaybillComponent, Playbill } from "@proscenium/playbill";
import { Glob } from "bun";
import z from "zod";
import { loadYAMLFileOrFail } from "#util";
import { ComponentFile } from "./component-file";
import { FileNotFoundError } from "./errors";
// binding.yaml file schema
const BindingSchema = z.object({
include: z.array(z.string()).default([]),
name: z.string().default("Unnamed playbill"),
author: z.string().default("Someone"),
description: z.string().default("No description provided"),
version: z.string().default("0.0.0"),
files: z.array(z.string()).default(["**/*.yaml", "**/*.md"]),
omitDefaultStyles: z.boolean().default(false),
styles: z
.union([z.string(), z.array(z.string())])
.transform((val) => (Array.isArray(val) ? val : [val]))
.default([]),
terms: z.record(z.string(), z.string()).default({}),
});
export interface BoundPlaybill {
// File information
bindingFilePath: string;
bindingFileDirname: string;
files: string[];
componentFiles: ComponentFile[];
// Binding properties
name: string;
author: string;
version: string;
description: string;
omitDefaultStyles: boolean;
styles: string;
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);
// If it is a directory, try again seeking a binding.yaml inside
const stat = await inputLocation.stat();
if (stat.isDirectory()) {
return resolveBindingPath(path.resolve(bindingPath, "binding.yaml"));
}
// If it doesnt exist, bail
if (!(await inputLocation.exists())) {
throw new FileNotFoundError(bindingPath);
}
// Resolve the path
return path.resolve(bindingPath);
}
export async function loadFromBinding(
bindingPath: string,
): Promise<BoundPlaybill> {
const resolvedBindingPath = await resolveBindingPath(bindingPath);
const yamlContent = await loadYAMLFileOrFail(resolvedBindingPath);
const binding = BindingSchema.parse(yamlContent);
const fileGlobs = binding.files;
const bindingFileDirname = path.dirname(resolvedBindingPath);
const playbill = new Playbill(
binding.name,
binding.author,
binding.description,
binding.version,
);
// If this is extending another binding, load that first
for (const includePath of binding.include) {
const pathFromHere = path.resolve(bindingFileDirname, includePath);
const extendBindingPath = await resolveBindingPath(pathFromHere);
const loadedBinding = await loadFromBinding(extendBindingPath);
playbill.extend(loadedBinding.playbill);
}
// Load any specified stylesheets
const loadedStyles: string[] = [];
for (const style of binding.styles) {
const cssFilePath = path.resolve(bindingFileDirname, style);
const cssFile = Bun.file(cssFilePath);
const cssFileExists = await cssFile.exists();
if (cssFileExists) {
loadedStyles.push(await cssFile.text());
} else {
throw new FileNotFoundError(cssFilePath);
}
}
const styles = loadedStyles.join("\n");
// Scan for all files matching the globs
const allFilePaths: string[] = [];
for (const thisGlob of fileGlobs) {
const glob = new Glob(thisGlob);
const results = glob.scanSync({
cwd: bindingFileDirname,
absolute: true,
followSymlinks: true,
onlyFiles: true,
});
allFilePaths.push(...results);
}
// Exclude the binding.yaml file
const filePathsWithoutBindingFile = allFilePaths.filter((filePath) => {
return filePath !== resolvedBindingPath;
});
// Load discovered component files
const componentFiles: ComponentFile[] = [];
const fileLoadPromises: Promise<void>[] = [];
for (const filepath of filePathsWithoutBindingFile) {
const componentFile = new ComponentFile(filepath);
componentFiles.push(componentFile);
fileLoadPromises.push(componentFile.load());
}
await Promise.all(fileLoadPromises);
// Aggregate all loaded components
const loadedComponents: AnyPlaybillComponent[] = [];
for (const file of componentFiles) {
loadedComponents.push(...file.components);
}
// Add all components to the playbill
// (This method ensures proper addition order maintain correctness)
playbill.addManyComponents(loadedComponents);
// Add all definitions
for (const [term, definition] of Object.entries(binding.terms)) {
playbill.addDefinition(term, definition);
}
return {
bindingFilePath: bindingPath,
bindingFileDirname,
files: filePathsWithoutBindingFile,
componentFiles,
name: binding.name,
author: binding.author ?? "Anonymous",
description: binding.description ?? "",
version: binding.version,
omitDefaultStyles: binding.omitDefaultStyles,
styles,
playbill,
};
}

View file

@ -1,177 +0,0 @@
import path from "node:path";
import { parsePlaybillComponent } from "define";
import YAML, { YAMLParseError } from "yaml";
import { ZodError } from "zod";
import { loadFileOrFail } from "#util";
import { MalformedResourceFileError } from "./errors";
import { toSlug } from "./slug";
import type { AnyPlaybillComponent } from "@proscenium/playbill";
type FileFormat = "yaml" | "markdown";
const FRONTMATTER_REGEX = /^---[\s\S]*?---/gm;
/**
* Attempt to parse YAML frontmatter from a mixed yaml/md doc
* @param content The raw markdown content
* @returns Any successfully parsed frontmatter
*/
function extractFrontmatter(content: string) {
// If it does not start with `---`, it is invalid for frontmatter
if (content.trim().indexOf("---") !== 0) {
return {};
}
if (FRONTMATTER_REGEX.test(content)) {
const frontmatterString = content.match(FRONTMATTER_REGEX)?.[0] ?? "";
const cleanFrontmatter = frontmatterString.replaceAll("---", "").trim();
return YAML.parse(cleanFrontmatter);
}
return {};
}
/**
* Given a string of a markdown document, extract the markdown content
* @param content The raw markdown content
* @returns The markdown content without frontmatter
*/
function extractMarkdown(content: string): string {
if (content.trim().indexOf("---") !== 0) {
return content;
}
return content.replace(FRONTMATTER_REGEX, "").trim();
}
function parseYAMLResourceFile(
filePath: string,
text: string,
): AnyPlaybillComponent[] {
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: AnyPlaybillComponent[] = [];
for (const doc of parsedDocs) {
const raw = doc.toJS();
const parsedComponent = parsePlaybillComponent(raw);
if (parsedComponent !== null) {
collection.push(parsedComponent);
}
}
return collection;
}
function parseMarkdownResourceFile(
filePath: string,
text: string,
): AnyPlaybillComponent[] {
try {
const defaultName = path.basename(filePath, ".md");
const defaultId = toSlug(defaultName);
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,
});
// Null means hidden
if (together === null) {
return [];
}
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: AnyPlaybillComponent[] = [];
private _basename: string;
constructor(filePath: string) {
this._filePath = path.resolve(filePath);
const extension = path.extname(filePath).slice(1).toLowerCase();
this._basename = path.basename(filePath, `.${extension}`);
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;
}
get baseName() {
return this._basename;
}
}

View file

@ -1,4 +0,0 @@
export * from "./errors";
export * from "./pfm";
export * from "./component-file";
export * from "./binding";

View file

@ -1,79 +0,0 @@
import rehypeStringify from "rehype-stringify";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
import type { BoundPlaybill } from "./binding";
import remarkWikiLink from "remark-wiki-link";
import { toSlug } from "./slug";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import rehypeShiftHeading from "rehype-shift-heading";
import {
parseProsceniumScript,
type AnyPlaybillComponent,
} from "@proscenium/playbill";
export type MarkdownParserFunction = (input: string) => Promise<string>;
export function createMarkdownRenderer(
binding: BoundPlaybill,
): MarkdownParserFunction {
// TODO: Allow specifying links to other files via file paths
// In this scenario, assume its a markdown-defined file and find the ID, use that for the link
// to allow for more natural Obsidian-like linking
// For now, this will mostly work, but if you need to specify a unique ID, it wont work in Obsidian
// despite being valid for Muse
const parser = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkWikiLink, {
aliasDivider: "|",
permalinks: binding.playbill.allComponentIds,
pageResolver: (permalink: string) => {
return [toSlug(permalink)];
},
hrefTemplate: (permalink: string) => `#component/${permalink}`,
})
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeShiftHeading, { shift: 1 })
.use(rehypeStringify);
// TODO: Add sanitization
return async (input: string): Promise<string> => {
const parsed = await parser.process(input);
return String(parsed);
};
}
export async function compileMarkdownInPlaybill(
boundPlaybill: BoundPlaybill,
): Promise<BoundPlaybill> {
const renderMarkdown = createMarkdownRenderer(boundPlaybill);
const playbill = boundPlaybill.playbill;
// Define a processor function to iterate over all components and process their markdown
const processMarkdownInComponent = async (entry: AnyPlaybillComponent) => {
const preparsed = parseProsceniumScript(entry.description, playbill);
entry.description = await renderMarkdown(preparsed);
if (entry._component === "resource" && entry.type === "table") {
const newData: string[][] = [];
for (const row of entry.data) {
const newRow: string[] = [];
for (const cell of row) {
newRow.push(await renderMarkdown(cell));
}
newData.push(newRow);
}
entry.data = newData;
}
};
// Process all components
await playbill.processComponents(processMarkdownInComponent);
return boundPlaybill;
}

View file

@ -1,5 +0,0 @@
import slugify from "slugify";
export function toSlug(str: string): string {
return slugify(str, { lower: true });
}

168
src/muse-file.ts Normal file
View file

@ -0,0 +1,168 @@
import type { BunFile } from "bun";
import { normalize, basename, extname, dirname } from "node:path";
import YAML from "yaml";
import TOML from "smol-toml";
import { FileError } from "./errors";
export type MuseFileType = "md" | "yaml" | "json" | "toml";
export interface MuseFileContent {
type: MuseFileType;
text: string;
data: Record<string, unknown>;
}
function extensionToFiletype(ext: string): MuseFileType {
switch (ext) {
case "md":
return "md";
case "yaml":
return "yaml";
case "json":
return "json";
case "toml":
return "toml";
default:
throw new Error(`Unsupported file format: ${ext}`);
}
}
const FRONTMATTER_REGEX = /^---[\s\S]*?---/gm;
/**
* Attempt to parse YAML frontmatter from a mixed yaml/md doc
* @param content The raw markdown content
* @returns Any successfully parsed frontmatter
*/
function extractFrontmatter(content: string) {
// If it does not start with `---`, it is invalid for frontmatter
if (content.trim().indexOf("---") !== 0) {
return {};
}
if (FRONTMATTER_REGEX.test(content)) {
const frontmatterString = content.match(FRONTMATTER_REGEX)?.[0] ?? "";
const cleanFrontmatter = frontmatterString.replaceAll("---", "").trim();
return YAML.parse(cleanFrontmatter);
}
return {};
}
/**
* Given a string of a markdown document, extract the markdown content
* @param content The raw markdown content
* @returns The markdown content without frontmatter
*/
function extractMarkdown(content: string): string {
if (content.trim().indexOf("---") !== 0) {
return content;
}
return content.replace(FRONTMATTER_REGEX, "").trim();
}
export class MuseFile {
protected _path: string;
protected _file: BunFile;
protected _fileType: MuseFileType;
constructor(filePath: string) {
this._path = normalize(filePath);
this._file = Bun.file(this._path);
this._fileType = extensionToFiletype(this.extension.slice(1));
}
get dirname(): string {
return dirname(this._path);
}
get extension(): string {
return extname(this._path);
}
get basename(): string {
return basename(this._path);
}
get filename(): string {
return basename(this._path, this.extension);
}
get path(): string {
return this._path;
}
public async readJSON(): Promise<MuseFileContent> {
try {
const text = await this._file.text();
return {
type: "json",
text: text,
data: JSON.parse(text),
};
} catch (error) {
throw new FileError(`Failed to read JSON file: ${error}`, this._path);
}
}
public async readYAML(): Promise<MuseFileContent> {
try {
const text = await this._file.text();
return {
type: "yaml",
text,
data: YAML.parse(text),
};
} catch (error) {
throw new FileError(`Failed to read YAML file: ${error}`, this._path);
}
}
public async readTOML(): Promise<MuseFileContent> {
try {
const text = await this._file.text();
return {
type: "toml",
text,
data: TOML.parse(text),
};
} catch (error) {
throw new FileError(`Failed to read TOML file: ${error}`, this._path);
}
}
public async readMarkdown(): Promise<MuseFileContent> {
try {
const text = await this._file.text();
const frontmatter = extractFrontmatter(text);
const markdown = extractMarkdown(text);
return {
type: "md",
text: markdown,
data: {
...frontmatter,
},
};
} catch (error) {
throw new FileError(`Failed to read Markdown file: ${error}`, this._path);
}
}
public async read(): Promise<MuseFileContent> {
switch (this._fileType) {
case "json":
return this.readJSON();
case "yaml":
return this.readYAML();
case "toml":
return this.readTOML();
case "md":
return this.readMarkdown();
default:
throw new FileError(
`No reader for file type ${this._fileType}`,
this._path,
);
}
}
}

View file

@ -1,67 +0,0 @@
import type { Ability } from "@proscenium/playbill";
import { HTML } from "./base/html";
interface AbilityCardProps {
ability: Ability;
}
export function AbilityCard({ ability }: AbilityCardProps) {
const costs: React.ReactNode[] = [];
if (ability.ap > 0) {
costs.push(
<span key="ap" className="ability-cost ap">
{ability.ap} AP
</span>,
);
}
if (ability.hp > 0) {
costs.push(
<span key="hp" className="ability-cost hp">
{ability.hp} HP
</span>,
);
}
if (ability.ep > 0) {
costs.push(
<span key="ep" className="ability-cost ep">
{ability.ep} EP
</span>,
);
}
const damage =
ability.damage !== null ? (
<div className={`ability-damage ${ability.damage.type}`}>
{ability.damage.amount} {ability.damage.type === "phy" ? "Phy" : "Arc"}
</div>
) : null;
const roll =
ability.roll !== "none" ? (
<div className={`ability-roll ${ability.roll}`}>{ability.roll}</div>
) : null;
const type = (
<span className={`ability-type ${ability.type}`}>{ability.type}</span>
);
const classList = `ability ability-${ability.type}`;
return (
<div className={classList} data-component-id={ability.id}>
<div className="ability-header">
<h4>{ability.name}</h4>
<div className="ability-details">
{type}
{roll}
{damage}
{costs}
</div>
</div>
<HTML className="ability-content" html={ability.description} />
</div>
);
}

View file

@ -1,10 +0,0 @@
interface HTMLWrapperProps {
html: string;
className?: string;
}
export function HTML({ html, className }: HTMLWrapperProps) {
return (
<div className={className} dangerouslySetInnerHTML={{ __html: html }} />
);
}

View file

@ -1,41 +0,0 @@
import classNames from "classnames";
import type React from "react";
interface SectionProps {
type: string;
title: string;
children: React.ReactNode;
preInfo?: React.ReactNode;
info?: React.ReactNode;
componentId?: string;
leftCornerTag?: React.ReactNode;
rightCornerTag?: React.ReactNode;
}
export function Section({
type,
title,
children,
preInfo,
info,
componentId,
leftCornerTag,
rightCornerTag,
}: SectionProps) {
const sectionClasses = classNames("section", type);
const headerClasses = classNames("section-header", `${type}-header`);
const contentClasses = classNames("section-content", `${type}-content`);
return (
<section className={sectionClasses} data-component-id={componentId}>
<div className={headerClasses}>
{preInfo}
<h2>{title}</h2>
{info}
</div>
<div className="section-tag section-tag--left">{leftCornerTag}</div>
<div className="section-tag section-tag--right">{rightCornerTag}</div>
<div className={contentClasses}>{children}</div>
</section>
);
}

View file

@ -1,26 +0,0 @@
import { Section } from "./base/section";
interface GlossaryTableProps {
terms: [string, string[]][];
}
export function GlossaryTable({ terms }: GlossaryTableProps) {
return (
<Section type="glossary" componentId="glossary" title="Glossary">
<div className="glossary-content">
<dl>
{terms.map(([term, defs]) => {
return (
<div key={term}>
<dt>{term}</dt>
{defs.map((d, i) => (
<dd key={i}>{d}</dd>
))}
</div>
);
})}
</dl>
</div>
</Section>
);
}

View file

@ -1,18 +0,0 @@
import type { Playbill } from "@proscenium/playbill";
interface PlaybillHeaderProps {
playbill: Playbill;
}
export function PageHeader({ playbill }: PlaybillHeaderProps) {
return (
<header>
<h1 className="header-title">{playbill.name}</h1>
<p className="header-byline">A Playbill by {playbill.author} </p>
<p>
{" "}
<small className="header-info"> Version {playbill.version}</small>
</p>
</header>
);
}

View file

@ -1,55 +0,0 @@
import type { Item } from "@proscenium/playbill";
import { capitalize } from "lodash-es";
import { Section } from "./base/section";
import { HTML } from "./base/html";
interface ItemSectionProps {
item: Item;
}
export function ItemCard({ item }: ItemSectionProps) {
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 infoParts: React.ReactNode[] = [];
if (item.affinity !== "none") {
infoParts.push(
<p key="affinity" className={`item-affinity ${item.affinity}`}>
{item.affinity} Affinity
</p>,
);
}
if (item.damage !== null && item.damage.amount > 0) {
infoParts.push(
<p key="damage" className={`item-damage ${item.damage.type}`}>
{item.damage.amount} {capitalize(item.damage.type)} Damage
</p>,
);
}
return (
<Section
type="item"
componentId={item.id}
title={item.name}
info={<div className="item-info">{infoParts}</div>}
rightCornerTag={capitalize(item.rarity)}
leftCornerTag={itemTypeDescriptor}
>
<HTML html={item.description} />
</Section>
);
}

View file

@ -1,49 +0,0 @@
import type { Method, Playbill } from "@proscenium/playbill";
import { AbilityCard } from "./ability";
import { Section } from "./base/section";
interface MethodSectionProps {
method: Method;
playbill: Playbill;
}
export function MethodSection({ method, playbill }: MethodSectionProps) {
const ranks = method.abilities.map((rank, i) => {
const gridTemplateColumns = new Array(Math.min(rank.length, 3))
.fill("1fr")
.join(" ");
return (
<div className="method-rank" key={i}>
<h3>Rank {i + 1}</h3>
<div className="method-rank-content" style={{ gridTemplateColumns }}>
{rank.map((abilityId) => {
const ability = playbill.getAbility(abilityId);
if (ability === null) {
throw new Error(`Ability not found: ${abilityId}`);
}
return <AbilityCard ability={ability} key={abilityId} />;
})}
</div>
</div>
);
});
return (
<Section
type="method"
componentId={method.id}
title={method.name}
preInfo={<p className="method-curator">{method.curator}'s Method of</p>}
info={
<p
className="method-description"
dangerouslySetInnerHTML={{ __html: method.description }}
/>
}
>
<div className="method-ranks">{ranks}</div>
</Section>
);
}

View file

@ -1,115 +0,0 @@
import type {
ImageResource,
Resource,
TableResource,
TextResource,
} from "@proscenium/playbill";
import { Section } from "./base/section";
import { HTML } from "./base/html";
interface TextResourceSectionProps {
resource: TextResource;
}
function TextResourceSection({ resource }: TextResourceSectionProps) {
return (
<Section
type="resource"
componentId={resource.id}
title={resource.name}
info={
<ul className="resource-categories">
{resource.categories.map((category) => (
<li key={category}>{category}</li>
))}
</ul>
}
>
<HTML html={resource.description} />
</Section>
);
}
interface ImageResourceSectionProps {
resource: ImageResource;
}
function ImageResourceSection({ resource }: ImageResourceSectionProps) {
return (
<Section
type="resource"
componentId={resource.id}
title={resource.name}
info={
<ul className="resource-categories">
{resource.categories.map((category) => (
<li key={category}>{category}</li>
))}
</ul>
}
>
<figure className="resource-image">
<img src={resource.url} alt={resource.name} />
<figcaption
dangerouslySetInnerHTML={{ __html: resource.description }}
/>
</figure>
</Section>
);
}
interface TableResourceSectionProps {
resource: TableResource;
}
function TableResourceSection({ resource }: TableResourceSectionProps) {
const [headers, ...rows] = resource.data;
return (
<Section
type="resource"
componentId={resource.id}
title={resource.name}
info={
<ul className="resource-categories">
{resource.categories.map((category) => (
<li key={category}>{category}</li>
))}
</ul>
}
>
<table className="resource-table">
<thead>
<tr>
{headers.map((header) => (
<th key={header} dangerouslySetInnerHTML={{ __html: header }} />
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i}>
{row.map((cell, j) => (
<td key={j} dangerouslySetInnerHTML={{ __html: cell }} />
))}
</tr>
))}
</tbody>
</table>
</Section>
);
}
interface ResourceSectionProps {
resource: Resource;
}
export function ResourceSection({ resource }: ResourceSectionProps) {
switch (resource.type) {
case "text":
return <TextResourceSection resource={resource} />;
case "image":
return <ImageResourceSection resource={resource} />;
case "table":
return <TableResourceSection resource={resource} />;
}
}

View file

@ -1,15 +0,0 @@
import type { Rule } from "@proscenium/playbill";
import { Section } from "./base/section";
import { HTML } from "./base/html";
interface RuleSectionProps {
rule: Rule;
}
export function RuleSection({ rule }: RuleSectionProps) {
return (
<Section title={rule.name} componentId={rule.id} type="rule">
<HTML html={rule.description} />
</Section>
);
}

View file

@ -1,120 +0,0 @@
import type { Playbill, Species } from "@proscenium/playbill";
import { Section } from "./base/section";
import { AbilityCard } from "./ability";
import { HTML } from "./base/html";
interface SpeciesSectionProps {
species: Species;
playbill: Playbill;
}
export function SpeciesSection({ species, playbill }: SpeciesSectionProps) {
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 ? (
<div className="species-abilities">
<h3>Innate Abilities</h3>
{species.abilities.map((abilityId) => {
const ability = playbill.getAbility(abilityId);
if (ability === null) {
throw new Error(`Ability not found: ${abilityId}`);
}
return <AbilityCard ability={ability} key={abilityId} />;
})}
</div>
) : (
""
);
const sectionContent = (
<>
<div className="species-stats">
<div className="species-stat-hp">
<h4 className="hp species-stat-hp-title" title="Health Points">
HP
</h4>
<p className="species-stat-hp-value">{hpString}</p>
</div>
<div className="species-stat-ap">
<h4 className="ap species-stat-ap-title" title="Action Points">
AP
</h4>
<p className="species-stat-ap-value">{apString}</p>
</div>
<div className="species-stat-ep">
<h4 className="ep species-stat-ep-title" title="Exhaustion Points">
EP
</h4>
<p className="species-stat-ep-value">{epString}</p>
</div>
</div>
<div className="species-talents">
<div className="species-talent-muscle">
<h4 className="species-talent-muscle-title muscle">Muscle</h4>
<p className={`species-talent-muscle-value ${species.muscle}`}>
{species.muscle}
</p>
</div>
<div className="species-talent-focus">
<h4 className="species-talent-focus-title focus">Focus</h4>
<p className={`species-talent-focus-value ${species.focus}`}>
{species.focus}
</p>
</div>
<div className="species-talent-knowledge">
<h4 className="species-talent-knowledge-title knowledge">
Knowledge
</h4>
<p className={`species-talent-knowledge-value ${species.knowledge}`}>
{species.knowledge}
</p>
</div>
<div className="species-talent-charm">
<h4 className="species-talent-charm-title charm">Charm</h4>
<p className={`species-talent-charm-value ${species.charm}`}>
{species.charm}
</p>
</div>
<div className="species-talent-cunning">
<h4 className="species-talent-cunning-title cunning">Cunning</h4>
<p className={`species-talent-cunning-value ${species.cunning}`}>
{species.cunning}
</p>
</div>
<div className="species-talent-spark">
<h4 className="species-talent-spark-title spark">Spark</h4>
<p className={`species-talent-spark-value ${species.spark}`}>
{species.spark}
</p>
</div>
</div>
<HTML className="species-content" html={species.description} />
{abilitySection}
</>
);
return (
<Section
type="species"
componentId={species.id}
title={species.name}
info={<p>Species</p>}
>
{sectionContent}
</Section>
);
}

View file

@ -1,505 +0,0 @@
@import url("https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Urbanist:ital,wght@0,100..900;1,100..900&display=swap");
:root {
/* Spacing and sizing */
--max-width: 800px;
--padding: 0.5rem;
--margin: 1rem;
/* Typography */
--line-height: 1.5;
--font-size: 18px;
--font-body: "Open Sans", sans-serif;
--font-header: "Urbanist", sans-serif;
/* Page Colors */
--color-background: hsl(34deg 18 15);
--color-background--dark: hsl(34deg 18 10);
--color-background--light: hsl(34deg 18 20);
--color-text: hsl(35deg 37 73);
--color-text--dim: hsl(35deg 17 50);
--color-heading: hsl(32deg 48 85);
--color-emphasis: hsl(56deg 59 56);
--color-strong: hsl(4deg 88 61);
/* Concept Colors */
--color-muscle: var(--color-heading);
--color-focus: var(--color-heading);
--color-knowledge: var(--color-heading);
--color-charm: var(--color-heading);
--color-cunning: var(--color-heading);
--color-spark: var(--color-heading);
--color-novice: gray;
--color-adept: pink;
--color-master: papayawhip;
--color-theatrical: red;
--color-phy: red;
--color-arc: hsl(56deg 59 56);
--color-ap: hsl(215deg 91 75);
--color-hp: hsl(15deg 91 75);
--color-ep: hsl(115deg 40 75);
--color-xp: hsl(0deg 0 45);
}
/* Normalize box sizing */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Strip default margins */
* {
margin: 0;
}
/* Global style classes */
.phy {
color: var(--color-phy);
}
.arc {
color: var(--color-arc);
}
.ap {
color: var(--color-ap);
}
.hp {
color: var(--color-hp);
}
.ep {
color: var(--color-ep);
}
.muscle {
color: var(--color-muscle);
}
.focus {
color: var(--color-focus);
}
.knowledge {
color: var(--color-knowledge);
}
.charm {
color: var(--color-charm);
}
.cunning {
color: var(--color-cunning);
}
.spark {
color: var(--color-spark);
}
.novice {
color: var(--color-novice);
}
.adept {
color: var(--color-adept);
}
.master {
color: var(--color-master);
}
.theatrical {
color: var(--color-theatrical);
}
/* Sections */
section {
position: relative;
max-width: var(--max-width);
margin: 0 auto var(--margin);
border: 2px solid var(--color-background--dark);
}
.section-header {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 8rem;
text-align: center;
background-color: var(--color-background--dark);
}
.section-header p {
margin: 0;
}
.section-content {
padding: var(--padding);
}
.section-content :last-child {
margin-bottom: 0;
}
.section-tag {
position: absolute;
top: var(--padding);
color: var(--color-text--dim);
font-weight: bold;
max-width: 45%;
}
.section-tag--left {
left: var(--padding);
}
.section-tag--right {
right: var(--padding);
}
body {
background-color: var(--color-background);
color: var(--color-text);
font-family: var(--font-body);
font-size: var(--font-size);
}
p {
margin-bottom: var(--margin);
line-height: var(--line-height);
}
em {
color: var(--color-emphasis);
}
strong {
color: var(--color-strong);
}
/* Basic Typography */
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-header);
color: var(--color-heading);
}
h1 {
font-size: 4rem;
text-align: center;
}
h2 {
font-size: 2rem;
}
h3 {
font-size: 1.75rem;
}
h4 {
font-size: 1.5rem;
}
h5 {
font-size: 1.25rem;
}
h6 {
font-weight: bold;
font-size: 1rem;
}
table {
width: 100%;
margin-bottom: var(--margin);
}
thead {
background-color: var(--color-background--light);
}
td {
padding: var(--padding);
text-align: left;
vertical-align: top;
}
tr:nth-child(even) {
background-color: var(--color-background--light);
}
tr:hover {
background-color: var(--color-background--dark);
}
article blockquote {
font-style: italic;
border-left: 3px solid var(--color-emphasis);
background-color: var(--color-background--light);
margin-bottom: var(--margin);
padding: 0.5rem 0.5rem 0.5rem 2rem;
}
article blockquote p:last-child {
margin-bottom: 0;
}
article img {
display: block;
margin: 0 auto;
max-width: 70%;
max-height: 35vh;
}
@media screen and (min-width: 480px) {
article img {
float: right;
margin-left: var(--margin);
margin-right: 0;
max-width: 45%;
}
article img.left {
float: left;
float: left;
margin-right: var(--margin);
}
}
/* Main Header */
header {
min-height: 20vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.header-byline {
font-size: 1.2rem;
}
/* Overview */
.overview {
padding: 1rem;
background-color: var(--color-background--dark);
text-align: center;
}
.overview p {
margin: 0 auto;
}
/* Nav */
nav {
max-width: var(--max-width);
padding: var(--padding);
margin: 0 auto;
display: flex;
flex-direction: row;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--padding);
}
nav button {
padding: var(--padding);
font-size: inherit;
color: inherit;
background-color: var(--color-background--light);
border: 1px solid var(--color-background--dark);
cursor: pointer;
flex: 1;
}
nav button:hover {
background-color: var(--color-background--dark);
}
/* Ability */
.ability {
padding: var(--padding);
background-color: var(--color-background--light);
}
.ability-details {
display: flex;
flex-direction: row;
gap: var(--padding);
flex-wrap: wrap;
text-transform: capitalize;
font-weight: 300;
font-style: italic;
}
.ability-header {
position: relative;
margin-bottom: var(--margin);
}
/* Method */
.method-rank {
margin-bottom: var(--margin);
overflow-x: auto;
}
.method-curator {
font-style: italic;
}
.method-description {
margin-top: var(--margin);
}
.method-rank-content {
display: grid;
gap: var(--padding);
}
.method-rank-content .ability {
width: 100%;
height: 100%;
}
/* Species */
.species {
margin-bottom: var(--margin);
}
.species-stats {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
text-align: center;
gap: var(--padding);
}
.species-talents .adept {
font-weight: 600;
}
.species-talents .master {
font-weight: 700;
}
.species-stats h4,
.species-talents h4 {
font-size: var(--font-size);
}
.species-talents {
text-transform: capitalize;
}
.species-talents {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--padding);
text-align: center;
margin-bottom: var(--margin);
}
.species-abilities .ability-cost-xp {
display: none;
}
/* Items */
.item {
position: relative;
background-color: var(--color-background--light);
}
.item-info {
display: flex;
flex-direction: row;
gap: var(--padding);
}
.item-affinity {
text-transform: capitalize;
}
.item-rarity {
position: absolute;
top: var(--padding);
right: var(--padding);
text-transform: capitalize;
}
/* Glossary */
.glossary-content > dl > div {
margin-bottom: var(--margin);
}
dt {
color: var(--color-strong);
font-weight: bold;
}
dd {
margin-left: 2ch;
}
dd + dd {
margin-top: var(--padding);
}
dd + dt {
margin-top: var(--margin);
}
/* Resources */
.resource-categories {
list-style: none;
padding: 0;
display: flex;
flex-direction: row;
text-transform: capitalize;
gap: var(--padding);
color: var(--color-text--dim);
}
.resource-categories li {
font-style: italic;
}
.resource-categories li:before {
content: "#";
opacity: 0.5;
}
.resource-image {
display: block;
}
.resource-image img {
float: none;
margin: 0 auto;
max-width: 90%;
max-height: 100vh;
margin-bottom: var(--padding);
}
.resource-image figcaption {
font-style: italic;
text-align: center;
}

View file

@ -1,160 +0,0 @@
import { Playbill } from "@proscenium/playbill";
import { compileMarkdownInPlaybill, type BoundPlaybill } 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";
import { renderToString } from "react-dom/server";
import defaultCSS from "./default.css" with { type: "text" };
export async function renderPlaybillToHTML(
boundPlaybill: BoundPlaybill,
): Promise<string> {
// Make a copy of the playbill to avoid modifying the original
// and compile all description markdown
const playbill = Playbill.fromSerialized(boundPlaybill.playbill.serialize());
await compileMarkdownInPlaybill(boundPlaybill);
// Prepare stylesheet
const cssParts: string[] = [];
if (!boundPlaybill.omitDefaultStyles) {
cssParts.push(`<style>${defaultCSS}</style>`);
}
if (boundPlaybill.styles.length > 0) {
cssParts.push(`<style>${boundPlaybill.styles}</style>`);
}
const css = cssParts.join("\n");
const body = renderToString(
<>
<article id="species" className="view">
{playbill.allSpecies.map((species) => (
<SpeciesSection
key={species.id}
species={species}
playbill={playbill}
/>
))}
</article>
<article id="methods" className="view" style={{ display: "none" }}>
{playbill.allMethods.map((method) => (
<MethodSection key={method.id} method={method} playbill={playbill} />
))}
</article>
<article id="items" className="view" style={{ display: "none" }}>
{playbill.allItems.map((item) => (
<ItemCard key={item.id} item={item} />
))}
</article>
<article id="rules" className="view" style={{ display: "none" }}>
{playbill.allRules.map((rule) => (
<RuleSection key={rule.id} rule={rule} />
))}
</article>
<article id="glossary" className="view" style={{ display: "none" }}>
{<GlossaryTable terms={playbill.allDefinitions} />}
</article>
<article id="resources" className="view" style={{ display: "none" }}>
{playbill.allResources.map((resource) => (
<ResourceSection key={resource.id} resource={resource} />
))}
</article>
</>,
);
// Main document
const doc = `
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Playbill: ${playbill.name}</title>
${css}
<script>
function removeHash() {
history.pushState("", document.title, window.location.pathname);
}
function setHash(hash) {
if (hash.length > 0) {
history.pushState(null, null, "#" + hash);
} else {
removeHash();
}
}
function hideAllViews() {
document.querySelectorAll(".view").forEach((view) => {
view.style.display = "none";
});
}
function showAllViews() {
document.querySelectorAll(".view").forEach((view) => {
view.style.display = "block";
});
setHash("");
}
function showView(viewId) {
document.getElementById(viewId).style.display = "block";
setHash(viewId);
}
function hideAllAndShow(viewId) {
hideAllViews();
showView(viewId);
}
function navigateFromHash() {
const hash = window.location.hash.slice(1);
if (hash.length > 0) {
const view = document.getElementById(hash);
if (view !== null) {
hideAllAndShow(hash);
} else {
showAllViews();
}
} else {
showAllViews();
}
}
document.addEventListener("DOMContentLoaded", navigateFromHash());
window.addEventListener("hashchange", navigateFromHash())
</script>
</head>
<body>
${renderToString(<PageHeader playbill={playbill} />)}
<blockquote class="overview"><p>${playbill.description}</p></blockquote>
<nav>
<button onclick="hideAllAndShow('species')" id="species-nav">Species</button>
<button onclick="hideAllAndShow('methods')" id="methods-nav">Methods</button>
<button onclick="hideAllAndShow('items')" id="items-nav">Items</button>
<button onclick="hideAllAndShow('rules')" id="rules-nav">Rulebook</button>
<button onclick="hideAllAndShow('glossary')" id="glossary-nav">Glossary</button>
<button onclick="hideAllAndShow('resources')" id="resources-nav">Resources</button>
<button onclick="showAllViews()" id="all-nav">Show All</button>
</nav>
${body}
</body>
</html>
`;
return doc;
}

View file

@ -1,111 +0,0 @@
import { parseArgs } from "node:util";
import { z } from "zod";
import { CLIError, MuseError } from "#lib";
import chalk from "chalk";
import { version } from "../../package.json" with { type: "json" };
export const USAGE = `
${chalk.bold("muse")} - Compile and validate Playbills for Proscenium
${chalk.dim(`v${version}`)}
Usage:
muse [/path/to/binding.yaml] <options>
Options:
--check Only load and check the current binding and resources, but do not compile
--outfile, -o Specify the output file path. If not specified, output to stdout
--watch, -w Watch the directory for changes and recompile
--renderer, -r Specify the output renderer. Options: json, html
--markdown Compile markdown in descriptions when rendering to JSON
--help, -h Show this help message
`.trim();
const Renderers = ["json", "html"] as const;
const RendererSchema = z.enum(Renderers);
type Renderer = z.infer<typeof RendererSchema>;
/**
* A shape representing the arguments passed to the CLI
*/
export interface CLIArguments {
inputFilePath: string;
options: {
check: boolean;
outfile: string;
help: boolean;
renderer: Renderer;
watch: boolean;
markdown: boolean;
};
}
/**
* Given an array of CLI arguments, parse them into a structured object
*
* @param argv The arguments to parse
* @returns The parsed CLI arguments
*
* @throws {CLIError} if the arguments are invalid
*/
export function parseCLIArguments(argv: string[]): CLIArguments {
const { values: options, positionals: args } = parseArgs({
args: argv,
options: {
check: {
short: "c",
type: "boolean",
default: false,
},
outfile: {
short: "o",
type: "string",
default: "",
},
help: {
short: "h",
default: false,
type: "boolean",
},
renderer: {
short: "r",
default: "json",
type: "string",
},
watch: {
default: false,
type: "boolean",
},
markdown: {
default: false,
type: "boolean",
},
},
strict: true,
allowPositionals: true,
});
// -- ARG VALIDATION -- //
if (options.check && options.outfile !== "") {
throw new CLIError("Cannot use --check and --outfile together");
}
const parsedRenderer = RendererSchema.safeParse(options.renderer);
if (!parsedRenderer.success) {
throw new MuseError(`Invalid renderer: ${parsedRenderer.data}`);
}
return {
inputFilePath: args[0] ?? "./binding.yaml",
options: {
check: options.check,
outfile: options.outfile,
help: options.help,
renderer: parsedRenderer.data,
watch: options.watch,
markdown: options.markdown,
},
};
}

View file

@ -1,65 +0,0 @@
import path from "node:path";
import YAML from "yaml";
import { FileError, FileNotFoundError } from "../lib/errors";
/**
* Load a file from the filesystem or throw an error if it does not exist.
* @param filePath The path to the file to load
* @returns The contents of the file as a string
*
* @throws {FileNotFoundError} if the file does not exist
*/
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();
}
/**
* Load and parse a YAML file or throw an error if the file doesnt exist
* or the yaml fails to parse
* @param filePath The path to the file to load
* @returns The parsed contents of the file
*
* @throws {FileNotFoundError} if the file does not exist
* @throws {FileError} if the file is not valid YAML
*/
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}`);
}
}
/**
* Load and parse a JSON file or throw an error if the file doesnt exist
* or the JSON fails to parse
* @param filePath The path to the file to load
* @returns The parsed contents of the file
*
* @throws {FileNotFoundError} if the file does not exist
* @throws {FileError} if the file is not valid JSON
*/
export async function loadJSONFileOrFail(filePath: string): Promise<unknown> {
try {
return JSON.parse(await loadFileOrFail(filePath));
} catch (error) {
throw new FileError("Failed to parse JSON file", filePath, `${error}`);
}
}
/**
* Returns the absolute path of the directory containing the given file
*
* @param filePath The path to the file
* @returns The absolute path of the directory containing the file
*/
export function getAbsoluteDirname(filePath: string): string {
return path.resolve(path.dirname(filePath));
}

View file

@ -1,2 +0,0 @@
export * from "./args";
export * from "./files";

View file

@ -14,7 +14,8 @@
"moduleResolution": "bundler",
"allowArbitraryExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"declaration": true,
"emitDeclarationOnly": true,
// Best practices
"strict": true,
"skipLibCheck": true,
@ -24,21 +25,10 @@
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"baseUrl": "./src",
"paths": {
"#lib": [
"./lib/index.ts"
],
"#render/*": [
"./render/*"
],
"#util": [
"./util/index.ts"
],
}
"paths": {}
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.css",
]
}