Generic muse
This commit is contained in:
parent
c66dd4d39c
commit
c1166680a8
31 changed files with 412 additions and 2221 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -176,3 +176,4 @@ dist
|
||||||
|
|
||||||
demo-data
|
demo-data
|
||||||
demo.html
|
demo.html
|
||||||
|
demo
|
||||||
|
|
3
bun.lock
3
bun.lock
|
@ -20,6 +20,7 @@
|
||||||
"remark-rehype": "^11.1.1",
|
"remark-rehype": "^11.1.1",
|
||||||
"remark-wiki-link": "^2.0.1",
|
"remark-wiki-link": "^2.0.1",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
|
"smol-toml": "^1.3.1",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"yaml": "^2.7.0",
|
"yaml": "^2.7.0",
|
||||||
"zod": "^3.24.1",
|
"zod": "^3.24.1",
|
||||||
|
@ -283,6 +284,8 @@
|
||||||
|
|
||||||
"slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
|
@ -30,6 +30,7 @@
|
||||||
"remark-rehype": "^11.1.1",
|
"remark-rehype": "^11.1.1",
|
||||||
"remark-wiki-link": "^2.0.1",
|
"remark-wiki-link": "^2.0.1",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
|
"smol-toml": "^1.3.1",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"yaml": "^2.7.0",
|
"yaml": "^2.7.0",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
|
|
56
src/args.ts
Normal file
56
src/args.ts
Normal 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
164
src/binding.ts
Normal 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
2
src/css.d.ts
vendored
|
@ -1,2 +0,0 @@
|
||||||
declare module '*.css' {
|
|
||||||
}
|
|
|
@ -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}`);
|
|
||||||
}
|
|
||||||
}
|
|
95
src/index.ts
95
src/index.ts
|
@ -1,62 +1,29 @@
|
||||||
import { watch } from "node:fs/promises";
|
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import {
|
import { MuseError } from "./errors";
|
||||||
CLIError,
|
import { loadBinding, type Binding } from "./binding";
|
||||||
MuseError,
|
import { type CLIArguments, parseCLIArguments, USAGE } from "./args";
|
||||||
compileMarkdownInPlaybill,
|
|
||||||
loadFromBinding,
|
|
||||||
resolveBindingPath,
|
|
||||||
} from "#lib";
|
|
||||||
import { renderPlaybillToHTML } from "#render/html";
|
|
||||||
import {
|
|
||||||
type CLIArguments,
|
|
||||||
getAbsoluteDirname,
|
|
||||||
parseCLIArguments,
|
|
||||||
USAGE,
|
|
||||||
} from "#util";
|
|
||||||
|
|
||||||
enum ExitCode {
|
enum ExitCode {
|
||||||
Success = 0,
|
Success = 0,
|
||||||
Error = 1,
|
Error = 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processBinding({ inputFilePath, options }: CLIArguments) {
|
async function processBinding({ inputFilePath }: CLIArguments) {
|
||||||
// Load the binding
|
// Load the binding
|
||||||
const bindingPath = await resolveBindingPath(inputFilePath);
|
const binding = await loadBinding(inputFilePath);
|
||||||
const binding = await loadFromBinding(bindingPath);
|
|
||||||
|
|
||||||
// If --check is specified, exit early
|
// Run the data through all processors
|
||||||
if (options.check) {
|
const processedSteps: Binding[] = [binding];
|
||||||
console.log(chalk.green("Playbill validated successfully"));
|
for (const processor of binding.processors) {
|
||||||
return ExitCode.Success;
|
const processedStep = await processor.process(binding);
|
||||||
|
processedSteps.push(processedStep);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.markdown) {
|
const finalState = processedSteps[processedSteps.length - 1];
|
||||||
await compileMarkdownInPlaybill(binding);
|
const serialized = JSON.stringify(finalState.entries, null, 2);
|
||||||
}
|
|
||||||
|
|
||||||
// Serialize (default: JSON)
|
// Otherwise
|
||||||
let serializedPlaybill = "";
|
console.log(serialized);
|
||||||
|
|
||||||
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);
|
|
||||||
return ExitCode.Success;
|
return ExitCode.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,39 +37,9 @@ async function main(): Promise<number> {
|
||||||
return ExitCode.Success;
|
return ExitCode.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastProcessResult = await processBinding(cliArguments);
|
const lastProcessResult = await processBinding(cliArguments);
|
||||||
|
|
||||||
if (options.watch) {
|
return lastProcessResult;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,4 +0,0 @@
|
||||||
export * from "./errors";
|
|
||||||
export * from "./pfm";
|
|
||||||
export * from "./component-file";
|
|
||||||
export * from "./binding";
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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
168
src/muse-file.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,10 +0,0 @@
|
||||||
interface HTMLWrapperProps {
|
|
||||||
html: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HTML({ html, className }: HTMLWrapperProps) {
|
|
||||||
return (
|
|
||||||
<div className={className} dangerouslySetInnerHTML={{ __html: html }} />
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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} />;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
111
src/util/args.ts
111
src/util/args.ts
|
@ -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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -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));
|
|
||||||
}
|
|
|
@ -1,2 +0,0 @@
|
||||||
export * from "./args";
|
|
||||||
export * from "./files";
|
|
|
@ -14,7 +14,8 @@
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowArbitraryExtensions": true,
|
"allowArbitraryExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"noEmit": true,
|
"declaration": true,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
// Best practices
|
// Best practices
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
@ -24,21 +25,10 @@
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
"baseUrl": "./src",
|
"baseUrl": "./src",
|
||||||
"paths": {
|
"paths": {}
|
||||||
"#lib": [
|
|
||||||
"./lib/index.ts"
|
|
||||||
],
|
|
||||||
"#render/*": [
|
|
||||||
"./render/*"
|
|
||||||
],
|
|
||||||
"#util": [
|
|
||||||
"./util/index.ts"
|
|
||||||
],
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.ts",
|
"src/**/*.ts",
|
||||||
"src/**/*.tsx",
|
"src/**/*.tsx",
|
||||||
"src/**/*.css",
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue