Update readme and clean up
This commit is contained in:
parent
6a3157762a
commit
435d555394
17 changed files with 320 additions and 486 deletions
225
README.md
225
README.md
|
@ -1,6 +1,20 @@
|
||||||
# Playbill Builder CLI
|
# Muse
|
||||||
|
|
||||||
This is a CLI tool for compiling Markdown and YAML files into a validated Playbill for [Proscenium](https://proscenium.game)
|
A CLI for wrangling directories of source files.
|
||||||
|
|
||||||
|
Like a static generator, but for whatever.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Muse** is a CLI and toolchain for operating on directories of
|
||||||
|
json, yaml, toml, and markdown files. Each file can specify any shape
|
||||||
|
of data. Muse scans included files, parses them into data, and streams
|
||||||
|
the loaded data through processors and plugins.
|
||||||
|
|
||||||
|
Each processor can modify the data at compile time, as well as enact
|
||||||
|
side effects such as writing files to disk.
|
||||||
|
|
||||||
|
Muse does not edit source files, it only reads them in.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
@ -9,209 +23,24 @@ Usage:
|
||||||
muse [/path/to/binding.yaml] <options>
|
muse [/path/to/binding.yaml] <options>
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--check Only load and check the current binding and resources, but do not compile
|
--stdout -s Output final data to stdout
|
||||||
--outfile, -o Specify the output file path. If not specified, output to stdout
|
--verbose, -v Enable verbose logging
|
||||||
--watch, -w Watch the directory for changes and recompile
|
|
||||||
--renderer, -r Specify the output renderer. Options: json, html
|
|
||||||
--help, -h Show this help message
|
--help, -h Show this help message
|
||||||
```
|
```
|
||||||
|
|
||||||
## Binding File
|
## Binding File
|
||||||
|
|
||||||
Each Muse project should specify a `binding.yaml` file with the following shape:
|
Each Muse project should specify a `binding` file with the following shape:
|
||||||
```yaml
|
```yaml
|
||||||
name: My Playbill # Required
|
include:
|
||||||
author: My Name # Required
|
- ../another-project # Optional
|
||||||
version: 0.0.1 # Required
|
|
||||||
extend: ../another-playbill # Optional
|
|
||||||
files: # Optional
|
files: # Optional
|
||||||
- "**/*.yaml"
|
- "**/*.yaml"
|
||||||
styles: # Optional
|
contentKey: "content" # Optional
|
||||||
- stylesheet.css
|
options: # Optional
|
||||||
terms: # Optional
|
someOption: someValue
|
||||||
Some Term: Some Definition
|
processors:
|
||||||
Another Term: Another Definition
|
- first-processor
|
||||||
|
- second-processor
|
||||||
```
|
```
|
||||||
|
|
||||||
The **Binding** file is used to specify the metadata for the Playbill, as well as the files to be compiled. It is also the entry point for a project.
|
|
||||||
|
|
||||||
When using the `muse` CLI, you must provide either a path to a `binding.yaml` file or a directory which contains a `binding.yaml` file.
|
|
||||||
|
|
||||||
## Common Types
|
|
||||||
|
|
||||||
Many Playbill components share common properties that expect a value
|
|
||||||
from a predefined list. These are the common types used in the Playbill.
|
|
||||||
|
|
||||||
### `Roll` Options
|
|
||||||
|
|
||||||
- `none`
|
|
||||||
- `attack`
|
|
||||||
- `fate`
|
|
||||||
- `muscle`
|
|
||||||
- `focus`
|
|
||||||
- `knowledge`
|
|
||||||
- `charm`
|
|
||||||
- `cunning`
|
|
||||||
- `spark`
|
|
||||||
|
|
||||||
### `Damage Type` Options
|
|
||||||
|
|
||||||
- `phy` (denotes "physical damage")
|
|
||||||
- `arc` (denotes "arcane damage")
|
|
||||||
|
|
||||||
### `Prowess` Options
|
|
||||||
|
|
||||||
- `novice`
|
|
||||||
- `adept`
|
|
||||||
- `master`
|
|
||||||
|
|
||||||
### `Challenge` Options
|
|
||||||
|
|
||||||
- `novice`
|
|
||||||
- `adept`
|
|
||||||
- `master`
|
|
||||||
- `theatrical`
|
|
||||||
|
|
||||||
### `Ability Type` Options
|
|
||||||
|
|
||||||
- `action`
|
|
||||||
- `cue`
|
|
||||||
- `trait`
|
|
||||||
|
|
||||||
## Definition Shapes
|
|
||||||
|
|
||||||
These YAML representations of definitions show the properties available for each Playbill component.
|
|
||||||
|
|
||||||
Note that every Playbill component definition can define the following properties:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
id: an-id # defaults to a slugified version of the file name
|
|
||||||
name: Component Name # defaults to the file name
|
|
||||||
description: Markdown description # defaults to a placeholder
|
|
||||||
categories: # defaults to no categories
|
|
||||||
- list
|
|
||||||
- of
|
|
||||||
- categories
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ability Definition
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
$define: ability
|
|
||||||
type: <ability type> # Defaults to action
|
|
||||||
ap: 0 # AP cost to use this ability (default 0)
|
|
||||||
hp: 0 # HP cost to use this ability (default 0)
|
|
||||||
ep: 0 # EP cost to use this ability (default 0)
|
|
||||||
xp: 0 # XP cost to learn this ability (default 1)
|
|
||||||
damage: 0 # Damage dealt by this ability (default 0)
|
|
||||||
damageType: <damage type> # Type of damage dealt by this ability (default phy)
|
|
||||||
roll: <roll type> # Roll type for this ability (default none)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Blueprint Definition
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
$define: blueprint
|
|
||||||
species: species-id # ID of the species this blueprint is for
|
|
||||||
items: # List of item IDs (default empty)
|
|
||||||
- item-id
|
|
||||||
abilities: # List of ability IDs (default empty)
|
|
||||||
- ability-id
|
|
||||||
ap: 0 # Starting AP (default 4)
|
|
||||||
hp: 0 # Starting HP (default 5)
|
|
||||||
ep: 0 # Starting EP (default 1)
|
|
||||||
xp: 0 # Starting XP (default 0)
|
|
||||||
muscle: <prowess> # Starting muscle prowess (default novice)
|
|
||||||
focus: <prowess> # Starting focus prowess (default novice)
|
|
||||||
knowledge: <prowess> # Starting knowledge prowess (default novice)
|
|
||||||
charm: <prowess> # Starting charm prowess (default novice)
|
|
||||||
cunning: <prowess> # Starting cunning prowess (default novice)
|
|
||||||
spark: <prowess> # Starting spark prowess (default novice)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Item Definition
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
$define: item
|
|
||||||
type: <item type> # Defaults to wielded
|
|
||||||
rarity: <rarity> # Defaults to common
|
|
||||||
affinity: <roll type> # Defaults to none
|
|
||||||
damage: 0 # Defaults to 0
|
|
||||||
damageType: <damage type> # Defaults to phy
|
|
||||||
hands: 1 | 2 # Only for wielded items
|
|
||||||
slot: <item slot> # Only for worn items
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `Item Type` Options
|
|
||||||
|
|
||||||
- `wielded`
|
|
||||||
- `worn`
|
|
||||||
- `trinket`
|
|
||||||
|
|
||||||
#### `Rarity` Options
|
|
||||||
|
|
||||||
- `common`
|
|
||||||
- `uncommon`
|
|
||||||
- `rare`
|
|
||||||
- `legendary`
|
|
||||||
|
|
||||||
#### `Item Slot` Options
|
|
||||||
|
|
||||||
- `headgear`
|
|
||||||
- `outfit`
|
|
||||||
- `gloves`
|
|
||||||
- `boots`
|
|
||||||
- `adornment`
|
|
||||||
|
|
||||||
### Method Definition
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
$define: method
|
|
||||||
curator: Someone # The name of the curator
|
|
||||||
abilities: # A 2-d array of ability IDs
|
|
||||||
- [rank-1-ability-id-one, rank-1-ability-id-two]
|
|
||||||
- [rank-2-ability-id-one]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Resource Definition
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
$define: resource
|
|
||||||
type: <resource type> # Defaults to text
|
|
||||||
url: https://url.com/image.jpeg # Only for image resources
|
|
||||||
data: <a 2-d array of strings> # Only for table resources
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `Resource Type` Options
|
|
||||||
|
|
||||||
- `text`
|
|
||||||
- `image`
|
|
||||||
- `table`
|
|
||||||
|
|
||||||
### Rule Definition
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
$define: rule
|
|
||||||
overrule: another-rule-id # Optional
|
|
||||||
order: 0 # Optional
|
|
||||||
```
|
|
||||||
|
|
||||||
### Species Definition
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
$define: species
|
|
||||||
hands: 2 # Defaults to 2
|
|
||||||
abilities: # A list of innate ability IDs
|
|
||||||
- some-ability-id
|
|
||||||
- another-ability-id
|
|
||||||
ap: 0 # Starting AP modifier
|
|
||||||
hp: 0 # Starting HP modifier
|
|
||||||
ep: 0 # Starting EP modifier
|
|
||||||
xp: 0 # Starting XP modifier
|
|
||||||
muscle: <prowess> # Defaults to novice
|
|
||||||
focus: <prowess> # Defaults to novice
|
|
||||||
knowledge: <prowess> # Defaults to novice
|
|
||||||
charm: <prowess> # Defaults to novice
|
|
||||||
cunning: <prowess> # Defaults to novice
|
|
||||||
spark: <prowess> # Defaults to novice
|
|
||||||
```
|
|
||||||
|
|
11
demo/main/binding.md
Normal file
11
demo/main/binding.md
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
markdownKey: description
|
||||||
|
options:
|
||||||
|
dunks:
|
||||||
|
key: dunkaroos
|
||||||
|
processors:
|
||||||
|
- ./processors/scream.js
|
||||||
|
- ./processors/announce-slam-dunks.js
|
||||||
|
---
|
||||||
|
|
||||||
|
A markdown-powered binding file!
|
|
@ -1,3 +0,0 @@
|
||||||
markdownKey: description
|
|
||||||
processors:
|
|
||||||
- ./processors/scream.js
|
|
13
demo/main/processors/announce-slam-dunks.js
Normal file
13
demo/main/processors/announce-slam-dunks.js
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
export const name = "Announce Slam Dunks";
|
||||||
|
|
||||||
|
export const process = (binding, { dunks }) => {
|
||||||
|
const slamDunkKey = dunks?.key ?? "slamDunks";
|
||||||
|
|
||||||
|
for (const entry of binding.entries) {
|
||||||
|
if (slamDunkKey in entry.data) {
|
||||||
|
console.log(`Slam dunk!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return binding;
|
||||||
|
};
|
|
@ -1,5 +1,5 @@
|
||||||
export const name = "Scream";
|
export const name = "Scream";
|
||||||
export function process(binding, opts) {
|
export function process(binding) {
|
||||||
const modifiedEntries = binding.entries.map(entry => {
|
const modifiedEntries = binding.entries.map(entry => {
|
||||||
if (binding.markdownKey in entry.data) {
|
if (binding.markdownKey in entry.data) {
|
||||||
entry.data = {
|
entry.data = {
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
slamDunks = 100
|
player = "Flynn"
|
||||||
|
dunkaroos = 100
|
||||||
|
|
21
package.json
21
package.json
|
@ -1,8 +1,17 @@
|
||||||
{
|
{
|
||||||
"name": "@proscenium/muse",
|
"name": "@proscenium/muse",
|
||||||
"version": "0.0.1",
|
"version": "0.1.0",
|
||||||
"module": "index.ts",
|
"module": "dist/index.js",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/muse.js",
|
||||||
|
"types": "./dist/muse.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
@ -20,9 +29,11 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"fmt": "bunx --bun biome check --fix",
|
"fmt": "bunx --bun biome check --fix",
|
||||||
"build": "bun build ./src/index.ts --outfile muse --compile",
|
"clean": "rm -rf dist",
|
||||||
"demo": "bun run ./src/index.ts -- ./demo-data/great-spires/binding.yaml",
|
"types": "dts-bundle-generator -o dist/muse.d.ts --project ./tsconfig.json ./src/exports.ts",
|
||||||
|
"compile": "bun build ./src/cli.ts --outfile ./dist/muse --compile",
|
||||||
|
"build": "bun run clean && bun run compile && bun run types",
|
||||||
"build:install": "bun run build && mv muse ~/.local/bin/muse",
|
"build:install": "bun run build && mv muse ~/.local/bin/muse",
|
||||||
"types": "dts-bundle-generator -o types/muse.d.ts --project ./tsconfig.json ./src/types.ts"
|
"demo": "bun run ./src/cli.ts -- ./demo/main"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
72
src/args.ts
72
src/args.ts
|
@ -1,72 +0,0 @@
|
||||||
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:
|
|
||||||
--stdout -s Output final data to stdout
|
|
||||||
--verbose, -v Enable verbose logging
|
|
||||||
--help, -h Show this help message
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A shape representing the arguments passed to the CLI
|
|
||||||
*/
|
|
||||||
export interface CLIArguments {
|
|
||||||
inputFilePath: string;
|
|
||||||
|
|
||||||
options: {
|
|
||||||
help: boolean;
|
|
||||||
verbose: boolean;
|
|
||||||
stdout: 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",
|
|
||||||
},
|
|
||||||
verbose: {
|
|
||||||
short: "v",
|
|
||||||
default: false,
|
|
||||||
type: "boolean",
|
|
||||||
},
|
|
||||||
stdout: {
|
|
||||||
short: "s",
|
|
||||||
default: false,
|
|
||||||
type: "boolean",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
strict: true,
|
|
||||||
allowPositionals: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
inputFilePath: args[0] ?? "./binding.yaml",
|
|
||||||
|
|
||||||
options: {
|
|
||||||
help: options.help,
|
|
||||||
verbose: options.verbose,
|
|
||||||
stdout: options.stdout,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
194
src/binding.ts
194
src/binding.ts
|
@ -1,26 +1,42 @@
|
||||||
import path, { dirname } from "node:path";
|
import path, { dirname } from "node:path";
|
||||||
import { Glob } from "bun";
|
import { Glob } from "bun";
|
||||||
import z from "zod";
|
|
||||||
import { FileNotFoundError, MuseError } from "./errors";
|
import { FileNotFoundError, MuseError } from "./errors";
|
||||||
import { parseMuseFile, type MuseEntry } from "./muse-file";
|
import { parseMuseFile } from "./parse";
|
||||||
|
import {
|
||||||
|
type Binding,
|
||||||
|
BindingSchema,
|
||||||
|
type MuseEntry,
|
||||||
|
type MuseProcessor,
|
||||||
|
} from "./types";
|
||||||
|
import { resolveFilePath } from "./util";
|
||||||
|
|
||||||
// binding.yaml file schema
|
async function loadProcessor(
|
||||||
const BindingSchema = z.object({
|
bindingDirname: string,
|
||||||
include: z.array(z.string()).default([]),
|
processorPath: string,
|
||||||
markdownKey: z.string().default("content"),
|
): Promise<MuseProcessor> {
|
||||||
files: z
|
const resolvedProcessorPath = path.resolve(bindingDirname, processorPath);
|
||||||
.array(z.string())
|
const { process, name, description } = await import(resolvedProcessorPath);
|
||||||
.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>;
|
if (!process || typeof process !== "function") {
|
||||||
|
throw new MuseError(
|
||||||
|
`Processor at ${processorPath} does not export a process() function`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const processorName = String(name ?? "Unnamed Processor");
|
||||||
|
const processorDescription = String(description ?? "No description provided");
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath: resolvedProcessorPath,
|
||||||
|
process: process as MuseProcessor["process"],
|
||||||
|
name: processorName,
|
||||||
|
description: processorDescription,
|
||||||
|
};
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Given a path, find the binding.yaml file
|
* Given a path, find the binding file
|
||||||
* If the path is to a directory, check for a binding.yaml inside
|
* If the path is to a directory, check for a binding inside
|
||||||
* If the path is to a file, check if it exists and is a binding.yaml
|
* Check order: binding.json, binding.yaml, binding.toml, binding.md
|
||||||
* Otherwise throw an error
|
* Otherwise throw an error
|
||||||
*
|
*
|
||||||
* @param bindingPath - The path to the binding file
|
* @param bindingPath - The path to the binding file
|
||||||
|
@ -30,45 +46,50 @@ export async function resolveBindingPath(bindingPath: string): Promise<string> {
|
||||||
// If the path does not specify a filename, use binding.yaml
|
// If the path does not specify a filename, use binding.yaml
|
||||||
const inputLocation = Bun.file(bindingPath);
|
const inputLocation = Bun.file(bindingPath);
|
||||||
|
|
||||||
// If it is a directory, try again seeking a binding.yaml inside
|
// If it is a directory, try for the four main supported types
|
||||||
const stat = await inputLocation.stat();
|
const stat = await inputLocation.stat();
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
return resolveBindingPath(path.resolve(bindingPath, "binding.yaml"));
|
const jsonPath = await resolveFilePath(
|
||||||
}
|
path.resolve(bindingPath, "binding.json"),
|
||||||
|
);
|
||||||
|
if (jsonPath) {
|
||||||
|
return jsonPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const yamlPath = await resolveFilePath(
|
||||||
|
path.resolve(bindingPath, "binding.yaml"),
|
||||||
|
);
|
||||||
|
if (yamlPath) {
|
||||||
|
return yamlPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tomlPath = await resolveFilePath(
|
||||||
|
path.resolve(bindingPath, "binding.toml"),
|
||||||
|
);
|
||||||
|
if (tomlPath) {
|
||||||
|
return tomlPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mdPath = await resolveFilePath(
|
||||||
|
path.resolve(bindingPath, "binding.md"),
|
||||||
|
);
|
||||||
|
if (mdPath) {
|
||||||
|
return mdPath;
|
||||||
|
}
|
||||||
|
|
||||||
// If it doesnt exist, bail
|
|
||||||
if (!(await inputLocation.exists())) {
|
|
||||||
throw new FileNotFoundError(bindingPath);
|
throw new FileNotFoundError(bindingPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the path
|
const exists = await inputLocation.exists();
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
throw new FileNotFoundError(bindingPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it is a file, return the path
|
||||||
return path.resolve(bindingPath);
|
return path.resolve(bindingPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MuseProcessorFn = (
|
|
||||||
binding: Binding,
|
|
||||||
opts: Record<string, unknown>,
|
|
||||||
) => Promise<Binding>;
|
|
||||||
|
|
||||||
export interface MuseProcessor {
|
|
||||||
readonly filePath: string;
|
|
||||||
readonly name: string;
|
|
||||||
readonly description: string;
|
|
||||||
readonly process: MuseProcessorFn;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Binding {
|
|
||||||
readonly _raw: ParsedBinding;
|
|
||||||
readonly bindingPath: string;
|
|
||||||
readonly markdownKey: string;
|
|
||||||
readonly includedFiles: string[];
|
|
||||||
readonly entries: MuseEntry[];
|
|
||||||
readonly meta: Record<string, unknown>;
|
|
||||||
readonly options: Record<string, unknown>;
|
|
||||||
readonly processors: MuseProcessor[];
|
|
||||||
readonly imports: Binding[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadBinding(initialPath: string): Promise<Binding> {
|
export async function loadBinding(initialPath: string): Promise<Binding> {
|
||||||
// Resolve the file location if possible
|
// Resolve the file location if possible
|
||||||
const bindingPath = await resolveBindingPath(initialPath);
|
const bindingPath = await resolveBindingPath(initialPath);
|
||||||
|
@ -95,68 +116,41 @@ export async function loadBinding(initialPath: string): Promise<Binding> {
|
||||||
entries.push(...loadedBinding.entries);
|
entries.push(...loadedBinding.entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect file manifest from globs
|
// Aggregate file paths to import
|
||||||
const allFilePaths: string[] = [];
|
const includedFilePaths = parsedBinding.files
|
||||||
for (const thisGlob of parsedBinding.files) {
|
.flatMap((thisGlob) => {
|
||||||
const glob = new Glob(thisGlob);
|
return Array.from(
|
||||||
|
new Glob(thisGlob).scanSync({
|
||||||
|
cwd: bindingDirname,
|
||||||
|
absolute: true,
|
||||||
|
followSymlinks: true,
|
||||||
|
onlyFiles: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter((filePath) => filePath !== bindingPath);
|
||||||
|
|
||||||
const results = glob.scanSync({
|
// Load files
|
||||||
cwd: bindingDirname,
|
const loadedEntries = await Promise.all(
|
||||||
absolute: true,
|
includedFilePaths.map((filePath) => {
|
||||||
followSymlinks: true,
|
return parseMuseFile(filePath, {
|
||||||
onlyFiles: true,
|
contentKey: parsedBinding.contentKey,
|
||||||
});
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
allFilePaths.push(...results);
|
// Add loaded entries to main list
|
||||||
}
|
entries.push(...loadedEntries.flat());
|
||||||
|
|
||||||
// Exclude the binding.yaml file
|
|
||||||
const includedFilePaths = allFilePaths.filter((filePath) => {
|
|
||||||
return filePath !== bindingPath;
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileLoadPromises: Promise<MuseEntry[]>[] = [];
|
|
||||||
for (const filePath of includedFilePaths) {
|
|
||||||
fileLoadPromises.push(
|
|
||||||
parseMuseFile(filePath, {
|
|
||||||
markdownKey: parsedBinding.markdownKey,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainEntries = await Promise.all(fileLoadPromises);
|
|
||||||
entries.push(...mainEntries.flat());
|
|
||||||
|
|
||||||
// Load and check processors
|
// Load and check processors
|
||||||
const processors: MuseProcessor[] = [];
|
const processors: MuseProcessor[] = await Promise.all(
|
||||||
for (const processorPath of parsedBinding.processors) {
|
parsedBinding.processors.map(loadProcessor.bind(null, bindingDirname)),
|
||||||
const resolvedProcessorPath = path.resolve(bindingDirname, processorPath);
|
);
|
||||||
const { process, name, description } = await import(resolvedProcessorPath);
|
|
||||||
|
|
||||||
if (!process || typeof process !== "function") {
|
|
||||||
throw new MuseError(
|
|
||||||
`Processor at ${processorPath} does not export a process() function`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const processorName = String(name ?? "Unnamed Processor");
|
|
||||||
const processorDescription = String(
|
|
||||||
description ?? "No description provided",
|
|
||||||
);
|
|
||||||
|
|
||||||
processors.push({
|
|
||||||
filePath: resolvedProcessorPath,
|
|
||||||
process: process as MuseProcessor["process"],
|
|
||||||
name: processorName,
|
|
||||||
description: processorDescription,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_raw: parsedBinding,
|
_raw: parsedBinding,
|
||||||
bindingPath,
|
bindingPath,
|
||||||
markdownKey: parsedBinding.markdownKey,
|
contentKey: parsedBinding.contentKey,
|
||||||
includedFiles: includedFilePaths,
|
|
||||||
entries,
|
entries,
|
||||||
options: parsedBinding.options,
|
options: parsedBinding.options,
|
||||||
processors: processors,
|
processors: processors,
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
|
import { parseArgs } from "node:util";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { MuseError } from "./errors.ts";
|
import { MuseError } from "./errors";
|
||||||
import { loadBinding, type Binding } from "./binding";
|
import { loadBinding } from "./binding";
|
||||||
import { type CLIArguments, parseCLIArguments, USAGE } from "./args";
|
import { version } from "../package.json";
|
||||||
|
import type { Binding, CLIArguments } from "./types";
|
||||||
|
|
||||||
interface Loggers {
|
interface Loggers {
|
||||||
log: (msg: string) => void;
|
log: (msg: string) => void;
|
||||||
|
@ -13,8 +15,62 @@ enum ExitCode {
|
||||||
Error = 1,
|
Error = 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const USAGE = `
|
||||||
|
${chalk.bold("muse")} - Compile and process troves of data
|
||||||
|
${chalk.dim(`v${version}`)}
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
muse [/path/to/binding.yaml] <options>
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--stdout -s Output final data to stdout
|
||||||
|
--verbose, -v Enable verbose logging
|
||||||
|
--help, -h Show this help message
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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",
|
||||||
|
},
|
||||||
|
verbose: {
|
||||||
|
short: "v",
|
||||||
|
default: false,
|
||||||
|
type: "boolean",
|
||||||
|
},
|
||||||
|
stdout: {
|
||||||
|
short: "s",
|
||||||
|
default: false,
|
||||||
|
type: "boolean",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
allowPositionals: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputFilePath: args[0] ?? "./binding.yaml",
|
||||||
|
flags: {
|
||||||
|
help: options.help,
|
||||||
|
verbose: options.verbose,
|
||||||
|
stdout: options.stdout,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
async function processBinding(
|
async function processBinding(
|
||||||
{ inputFilePath, options }: CLIArguments,
|
{ inputFilePath, flags }: CLIArguments,
|
||||||
{ log, verbose }: Loggers,
|
{ log, verbose }: Loggers,
|
||||||
) {
|
) {
|
||||||
// Load the binding
|
// Load the binding
|
||||||
|
@ -37,7 +93,7 @@ async function processBinding(
|
||||||
const { process, name } = processor;
|
const { process, name } = processor;
|
||||||
|
|
||||||
log(chalk.bold(`↪ ${name}`) + chalk.dim(` (${processor.description})`));
|
log(chalk.bold(`↪ ${name}`) + chalk.dim(` (${processor.description})`));
|
||||||
const thisStep = await process(lastStep, binding.options);
|
const thisStep = await process(lastStep, binding.options, flags);
|
||||||
processedSteps.push(thisStep);
|
processedSteps.push(thisStep);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,7 +104,7 @@ async function processBinding(
|
||||||
const finalState = processedSteps[processedSteps.length - 1];
|
const finalState = processedSteps[processedSteps.length - 1];
|
||||||
const serialized = JSON.stringify(finalState.entries, null, 2);
|
const serialized = JSON.stringify(finalState.entries, null, 2);
|
||||||
|
|
||||||
if (options.stdout) {
|
if (flags.stdout) {
|
||||||
console.log(serialized);
|
console.log(serialized);
|
||||||
}
|
}
|
||||||
return ExitCode.Success;
|
return ExitCode.Success;
|
||||||
|
@ -56,16 +112,16 @@ async function processBinding(
|
||||||
|
|
||||||
async function main(): Promise<number> {
|
async function main(): Promise<number> {
|
||||||
const cliArguments = parseCLIArguments(Bun.argv.slice(2));
|
const cliArguments = parseCLIArguments(Bun.argv.slice(2));
|
||||||
const { options } = cliArguments;
|
const { flags } = cliArguments;
|
||||||
|
|
||||||
// If --help is specified, print usage and exit
|
// If --help is specified, print usage and exit
|
||||||
if (options.help) {
|
if (flags.help) {
|
||||||
console.log(USAGE);
|
console.log(USAGE);
|
||||||
return ExitCode.Success;
|
return ExitCode.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logFn = !options.stdout ? console.log : () => {};
|
const logFn = !flags.stdout ? console.log : () => {};
|
||||||
const verboseFn = options.verbose
|
const verboseFn = flags.verbose
|
||||||
? (msg: string) => {
|
? (msg: string) => {
|
||||||
console.log(chalk.dim(msg));
|
console.log(chalk.dim(msg));
|
||||||
}
|
}
|
|
@ -19,8 +19,6 @@ export class FileError extends MuseError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MalformedResourceFileError extends FileError {}
|
|
||||||
|
|
||||||
export class FileNotFoundError extends FileError {
|
export class FileNotFoundError extends FileError {
|
||||||
constructor(filePath: string) {
|
constructor(filePath: string) {
|
||||||
super("File not found", filePath);
|
super("File not found", filePath);
|
||||||
|
|
2
src/exports.ts
Normal file
2
src/exports.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export type * from "./types";
|
||||||
|
export const DEFAULT_CONTENT_KEY = "content";
|
|
@ -2,30 +2,7 @@ import { normalize, extname } from "node:path";
|
||||||
import YAML from "yaml";
|
import YAML from "yaml";
|
||||||
import TOML from "smol-toml";
|
import TOML from "smol-toml";
|
||||||
import EMDY from "@endeavorance/emdy";
|
import EMDY from "@endeavorance/emdy";
|
||||||
|
import type { MuseEntry, UnknownRecord } from "./types";
|
||||||
type UnknownRecord = Record<string, unknown>;
|
|
||||||
export type MuseFileType = "md" | "yaml" | "json" | "toml";
|
|
||||||
export interface MuseEntry {
|
|
||||||
_raw: string;
|
|
||||||
filePath: string;
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
meta: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseFileType(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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseYAMLEntries(text: string): UnknownRecord[] {
|
function parseYAMLEntries(text: string): UnknownRecord[] {
|
||||||
const parsedDocs = YAML.parseAllDocuments(text);
|
const parsedDocs = YAML.parseAllDocuments(text);
|
||||||
|
@ -75,16 +52,16 @@ function parseTOMLEntries(text: string): UnknownRecord[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParseMuseFileOptions {
|
interface ParseMuseFileOptions {
|
||||||
markdownKey?: string;
|
contentKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseMuseFile(
|
export async function parseMuseFile(
|
||||||
rawFilePath: string,
|
rawFilePath: string,
|
||||||
{ markdownKey = "content" }: ParseMuseFileOptions = {},
|
{ contentKey = "content" }: ParseMuseFileOptions = {},
|
||||||
): Promise<MuseEntry[]> {
|
): Promise<MuseEntry[]> {
|
||||||
const filePath = normalize(rawFilePath);
|
const filePath = normalize(rawFilePath);
|
||||||
const file = Bun.file(filePath);
|
const file = Bun.file(filePath);
|
||||||
const fileType = parseFileType(extname(filePath).slice(1));
|
const fileType = extname(filePath).slice(1);
|
||||||
const rawFileContent = await file.text();
|
const rawFileContent = await file.text();
|
||||||
|
|
||||||
const partial = {
|
const partial = {
|
||||||
|
@ -94,7 +71,7 @@ export async function parseMuseFile(
|
||||||
};
|
};
|
||||||
|
|
||||||
if (fileType === "md") {
|
if (fileType === "md") {
|
||||||
const parsed = EMDY.parse(rawFileContent, markdownKey);
|
const parsed = EMDY.parse(rawFileContent, contentKey);
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...partial,
|
...partial,
|
58
src/types.ts
58
src/types.ts
|
@ -1,3 +1,55 @@
|
||||||
import type { Binding, MuseProcessorFn } from "./binding";
|
import { z } from "zod";
|
||||||
import type { MuseEntry } from "./muse-file";
|
|
||||||
export type { Binding, MuseProcessorFn, MuseEntry };
|
export interface CLIFlags {
|
||||||
|
help: boolean;
|
||||||
|
verbose: boolean;
|
||||||
|
stdout: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIArguments {
|
||||||
|
inputFilePath: string;
|
||||||
|
flags: CLIFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BindingSchema = z.object({
|
||||||
|
include: z.array(z.string()).default([]),
|
||||||
|
contentKey: z.string().default("content"),
|
||||||
|
files: z
|
||||||
|
.array(z.string())
|
||||||
|
.default(["**/*.yaml", "**/*.md", "**/*.toml", "**/*.json"]),
|
||||||
|
options: z.record(z.string(), z.any()).default({}),
|
||||||
|
processors: z.array(z.string()).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface Binding {
|
||||||
|
readonly _raw: z.infer<typeof BindingSchema>;
|
||||||
|
readonly bindingPath: string;
|
||||||
|
readonly contentKey: string;
|
||||||
|
readonly entries: MuseEntry[];
|
||||||
|
readonly meta: Record<string, unknown>;
|
||||||
|
readonly options: Record<string, unknown>;
|
||||||
|
readonly processors: MuseProcessor[];
|
||||||
|
readonly imports: Binding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MuseProcessorFn = (
|
||||||
|
binding: Binding,
|
||||||
|
opts: Record<string, unknown>,
|
||||||
|
flags: CLIFlags,
|
||||||
|
) => Promise<Binding>;
|
||||||
|
|
||||||
|
export interface MuseProcessor {
|
||||||
|
readonly filePath: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly process: MuseProcessorFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MuseEntry {
|
||||||
|
_raw: string;
|
||||||
|
filePath: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
meta: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UnknownRecord = Record<string, unknown>;
|
||||||
|
|
27
src/util.ts
Normal file
27
src/util.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { normalize } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a file path, ensure it is a file that exists
|
||||||
|
* Otherwise, return null
|
||||||
|
*
|
||||||
|
* @param filePath - The path to the file
|
||||||
|
* @returns A promise which resolves to the resolved file path or null
|
||||||
|
*/
|
||||||
|
export async function resolveFilePath(
|
||||||
|
filePath: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const normalized = normalize(filePath);
|
||||||
|
const file = Bun.file(normalized);
|
||||||
|
const exists = await file.exists();
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await file.stat();
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
|
@ -27,7 +27,7 @@
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
"baseUrl": "./src",
|
"baseUrl": "./src",
|
||||||
"paths": {},
|
"paths": {},
|
||||||
"outDir": "./types",
|
"outDir": "./dist",
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.ts",
|
"src/**/*.ts",
|
||||||
|
|
62
types/muse.d.ts
vendored
62
types/muse.d.ts
vendored
|
@ -1,62 +0,0 @@
|
||||||
// Generated by dts-bundle-generator v9.5.1
|
|
||||||
|
|
||||||
import z from 'zod';
|
|
||||||
|
|
||||||
export type MuseFileType = "md" | "yaml" | "json" | "toml";
|
|
||||||
export interface MuseFileContent {
|
|
||||||
type: MuseFileType;
|
|
||||||
text: string;
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
declare const BindingSchema: z.ZodObject<{
|
|
||||||
include: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
||||||
name: z.ZodDefault<z.ZodString>;
|
|
||||||
author: z.ZodDefault<z.ZodString>;
|
|
||||||
description: z.ZodDefault<z.ZodString>;
|
|
||||||
version: z.ZodDefault<z.ZodString>;
|
|
||||||
files: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
||||||
options: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodAny>>;
|
|
||||||
processors: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
||||||
}, "strip", z.ZodTypeAny, {
|
|
||||||
options: Record<string, any>;
|
|
||||||
include: string[];
|
|
||||||
name: string;
|
|
||||||
author: string;
|
|
||||||
description: string;
|
|
||||||
version: string;
|
|
||||||
files: string[];
|
|
||||||
processors: string[];
|
|
||||||
}, {
|
|
||||||
options?: Record<string, any> | undefined;
|
|
||||||
include?: string[] | undefined;
|
|
||||||
name?: string | undefined;
|
|
||||||
author?: string | undefined;
|
|
||||||
description?: string | undefined;
|
|
||||||
version?: string | undefined;
|
|
||||||
files?: string[] | undefined;
|
|
||||||
processors?: string[] | undefined;
|
|
||||||
}>;
|
|
||||||
export type ParsedBinding = z.infer<typeof BindingSchema>;
|
|
||||||
export interface BindingMetadata {
|
|
||||||
name: string;
|
|
||||||
author: string;
|
|
||||||
description: string;
|
|
||||||
version: string;
|
|
||||||
}
|
|
||||||
export type MuseProcessorFn = (binding: Binding) => Promise<Binding>;
|
|
||||||
export interface MuseProcessor {
|
|
||||||
readonly filePath: string;
|
|
||||||
readonly process: MuseProcessorFn;
|
|
||||||
}
|
|
||||||
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 {};
|
|
Loading…
Add table
Add a link
Reference in a new issue