Compare commits
No commits in common. "main" and "muse-gen" have entirely different histories.
20 changed files with 399 additions and 697 deletions
61
README.md
61
README.md
|
@ -1,17 +1,20 @@
|
||||||
# Muse
|
# Muse
|
||||||
|
|
||||||
_Bind data into anything_
|
A CLI for wrangling directories of source files.
|
||||||
|
|
||||||
|
Like a static generator, but for whatever.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
**Muse** is a CLI and toolchain for operating on data collected from
|
**Muse** is a CLI and toolchain for operating on directories of
|
||||||
json, yaml, toml, and markdown files.
|
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.
|
||||||
|
|
||||||
Muse scans included files, parses them into data, and streams
|
Each processor can modify the data at compile time, as well as enact
|
||||||
the loaded data through plugins.
|
side effects such as writing files to disk.
|
||||||
|
|
||||||
Each plugin can modify the data as well as enact side effects
|
Muse does not edit source files, it only reads them in.
|
||||||
such as writing files to disk.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
@ -29,9 +32,10 @@ Options:
|
||||||
|
|
||||||
Each Muse project should specify a `binding` file with the following shape:
|
Each Muse project should specify a `binding` file with the following shape:
|
||||||
```yaml
|
```yaml
|
||||||
sources: # Optional
|
include:
|
||||||
- file: "**/*.yaml"
|
- ../another-project # Optional
|
||||||
- web: "https://example.com/data.json"
|
files: # Optional
|
||||||
|
- "**/*.yaml"
|
||||||
contentKey: "content" # Optional
|
contentKey: "content" # Optional
|
||||||
options: # Optional
|
options: # Optional
|
||||||
someOption: someValue
|
someOption: someValue
|
||||||
|
@ -40,9 +44,9 @@ processors:
|
||||||
- second-processor
|
- second-processor
|
||||||
```
|
```
|
||||||
|
|
||||||
The `binding` file can be any of the supported file types for Muse and can
|
The `binding` file can be any of the supported file types for Muse. If
|
||||||
be named anything. If the CLI is invoked with a directory instead of a file,
|
the CLI is invoked with a directory instead of a file, Muse will look
|
||||||
Muse will look for a binding file in the directory with the following order:
|
for a binding file in the directory with the following order:
|
||||||
|
|
||||||
1. `binding.json`
|
1. `binding.json`
|
||||||
2. `binding.yaml`
|
2. `binding.yaml`
|
||||||
|
@ -64,34 +68,3 @@ Your markdown content here
|
||||||
When loading markdown files, Muse will load the content of the file
|
When loading markdown files, Muse will load the content of the file
|
||||||
into a key called `content` by default. This can be changed in the binding
|
into a key called `content` by default. This can be changed in the binding
|
||||||
configuration by setting a `contentKey` value as an override.
|
configuration by setting a `contentKey` value as an override.
|
||||||
|
|
||||||
## Plugin API
|
|
||||||
|
|
||||||
Muse plugins are written in TypeScript/JavaScript and expose information
|
|
||||||
describing the plugin, as well as the functions to operate with:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const name = "Plugin Name";
|
|
||||||
export const description = "Plugin Description";
|
|
||||||
export async function step(binding: Binding): Promise<Binding> {}
|
|
||||||
```
|
|
||||||
|
|
||||||
The main function of a plugin is `step`, which takes a `Binding` object
|
|
||||||
and returns a modified `Binding` object.
|
|
||||||
|
|
||||||
When returning a new Binding object, it is best practice to make immutable
|
|
||||||
changes:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export async function step(binding: Binding): Promise<Binding> {
|
|
||||||
const newBinding = { ...binding };
|
|
||||||
newBinding.options.someOption = "newValue";
|
|
||||||
return {
|
|
||||||
...binding,
|
|
||||||
meta: {
|
|
||||||
...binding.meta,
|
|
||||||
customValue: true,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
|
@ -7,7 +7,10 @@
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false,
|
"ignoreUnknown": false,
|
||||||
"ignore": ["*.d.ts", "*.json"],
|
"ignore": [
|
||||||
|
"*.d.ts",
|
||||||
|
"*.json"
|
||||||
|
],
|
||||||
"include": [
|
"include": [
|
||||||
"./src/**/*.ts",
|
"./src/**/*.ts",
|
||||||
"./src/**/*.tsx",
|
"./src/**/*.tsx",
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
preload = ["./src/preload/http-plugin.js"]
|
|
|
@ -3,9 +3,6 @@ markdownKey: description
|
||||||
options:
|
options:
|
||||||
dunks:
|
dunks:
|
||||||
key: dunkaroos
|
key: dunkaroos
|
||||||
sources:
|
|
||||||
- file: ./stats/*.toml
|
|
||||||
- url: https://git.astral.camp/endeavorance/emdy/raw/branch/main/package.json
|
|
||||||
processors:
|
processors:
|
||||||
- ./processors/scream.js
|
- ./processors/scream.js
|
||||||
- ./processors/announce-slam-dunks.js
|
- ./processors/announce-slam-dunks.js
|
||||||
|
|
|
@ -1,19 +1,13 @@
|
||||||
export const name = "Announce Slam Dunks";
|
export const name = "Announce Slam Dunks";
|
||||||
export const description = "Get hype when a file has slam dunks";
|
|
||||||
|
|
||||||
export const step = ({ entries, options, ...rest }) => {
|
export const process = (binding, { dunks }) => {
|
||||||
const { dunks } = options;
|
|
||||||
const slamDunkKey = dunks?.key ?? "slamDunks";
|
const slamDunkKey = dunks?.key ?? "slamDunks";
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of binding.entries) {
|
||||||
if (slamDunkKey in entry.data) {
|
if (slamDunkKey in entry.data) {
|
||||||
console.log(`Slam dunk!`);
|
console.log(`Slam dunk!`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return binding;
|
||||||
entries,
|
|
||||||
options,
|
|
||||||
...rest,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
export function step(binding) {
|
export const name = "Scream";
|
||||||
|
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 = {
|
||||||
|
|
13
package.json
13
package.json
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@endeavorance/muse",
|
"name": "@proscenium/muse",
|
||||||
"version": "0.3.0",
|
"version": "0.1.0",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
@ -31,10 +31,9 @@
|
||||||
"fmt": "bunx --bun biome check --fix",
|
"fmt": "bunx --bun biome check --fix",
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"types": "dts-bundle-generator -o dist/muse.d.ts --project ./tsconfig.json ./src/exports.ts",
|
"types": "dts-bundle-generator -o dist/muse.d.ts --project ./tsconfig.json ./src/exports.ts",
|
||||||
"transpile": "bun build ./src/exports.ts --outfile ./dist/muse.js",
|
"compile": "bun build ./src/cli.ts --outfile ./dist/muse --compile",
|
||||||
"compile": "bun run clean && bun build ./src/cli/index.ts --outfile ./dist/muse --compile",
|
"build": "bun run clean && bun run compile && bun run types",
|
||||||
"compile:install": "bun run compile && mv ./dist/muse ~/.local/bin/muse",
|
"build:install": "bun run build && mv muse ~/.local/bin/muse",
|
||||||
"build": "bun run clean && bun run transpile && bun run types",
|
"demo": "bun run ./src/cli.ts -- ./demo/main"
|
||||||
"demo": "bun run ./src/cli/index.ts -- ./demo/main"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
160
src/binding.ts
Normal file
160
src/binding.ts
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
import path, { dirname } from "node:path";
|
||||||
|
import { Glob } from "bun";
|
||||||
|
import { FileNotFoundError, MuseError } from "./errors";
|
||||||
|
import { parseMuseFile } from "./parse";
|
||||||
|
import {
|
||||||
|
type Binding,
|
||||||
|
BindingSchema,
|
||||||
|
type MuseEntry,
|
||||||
|
type MuseProcessor,
|
||||||
|
} from "./types";
|
||||||
|
import { resolveFilePath } from "./util";
|
||||||
|
|
||||||
|
async function loadProcessor(
|
||||||
|
bindingDirname: string,
|
||||||
|
processorPath: string,
|
||||||
|
): Promise<MuseProcessor> {
|
||||||
|
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");
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath: resolvedProcessorPath,
|
||||||
|
process: process as MuseProcessor["process"],
|
||||||
|
name: processorName,
|
||||||
|
description: processorDescription,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Given a path, find the binding file
|
||||||
|
* If the path is to a directory, check for a binding inside
|
||||||
|
* Check order: binding.json, binding.yaml, binding.toml, binding.md
|
||||||
|
* 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 for the four main supported types
|
||||||
|
const stat = await inputLocation.stat();
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundError(bindingPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await inputLocation.exists();
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
throw new FileNotFoundError(bindingPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it is a file, return the path
|
||||||
|
return path.resolve(bindingPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadBinding(initialPath: string): Promise<Binding> {
|
||||||
|
// Resolve the file location if possible
|
||||||
|
const bindingPath = await resolveBindingPath(initialPath);
|
||||||
|
const bindingDirname = dirname(bindingPath);
|
||||||
|
const loadedBindingData = await parseMuseFile(bindingPath);
|
||||||
|
|
||||||
|
if (loadedBindingData.length === 0) {
|
||||||
|
throw new MuseError("No entries found in binding file");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and parse the Binding YAML content
|
||||||
|
const parsedBinding = BindingSchema.parse(loadedBindingData[0].data);
|
||||||
|
|
||||||
|
// Prepare collections to load into
|
||||||
|
const extendsFrom: Binding[] = [];
|
||||||
|
const entries: MuseEntry[] = [];
|
||||||
|
|
||||||
|
// Process imported bindings and bring in their entries
|
||||||
|
for (const includePath of parsedBinding.include) {
|
||||||
|
const pathFromHere = path.resolve(bindingDirname, includePath);
|
||||||
|
const extendBindingPath = await resolveBindingPath(pathFromHere);
|
||||||
|
const loadedBinding = await loadBinding(extendBindingPath);
|
||||||
|
extendsFrom.push(loadedBinding);
|
||||||
|
entries.push(...loadedBinding.entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate file paths to import
|
||||||
|
const includedFilePaths = parsedBinding.files
|
||||||
|
.flatMap((thisGlob) => {
|
||||||
|
return Array.from(
|
||||||
|
new Glob(thisGlob).scanSync({
|
||||||
|
cwd: bindingDirname,
|
||||||
|
absolute: true,
|
||||||
|
followSymlinks: true,
|
||||||
|
onlyFiles: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter((filePath) => filePath !== bindingPath);
|
||||||
|
|
||||||
|
// Load files
|
||||||
|
const loadedEntries = await Promise.all(
|
||||||
|
includedFilePaths.map((filePath) => {
|
||||||
|
return parseMuseFile(filePath, {
|
||||||
|
contentKey: parsedBinding.contentKey,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add loaded entries to main list
|
||||||
|
entries.push(...loadedEntries.flat());
|
||||||
|
|
||||||
|
// Load and check processors
|
||||||
|
const processors: MuseProcessor[] = await Promise.all(
|
||||||
|
parsedBinding.processors.map(loadProcessor.bind(null, bindingDirname)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
_raw: parsedBinding,
|
||||||
|
bindingPath,
|
||||||
|
contentKey: parsedBinding.contentKey,
|
||||||
|
entries,
|
||||||
|
options: parsedBinding.options,
|
||||||
|
processors: processors,
|
||||||
|
imports: extendsFrom,
|
||||||
|
meta: {},
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,15 +1,9 @@
|
||||||
import { parseArgs } from "node:util";
|
import { parseArgs } from "node:util";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { loadBinding } from "#core/binding";
|
import { MuseError } from "./errors";
|
||||||
import type { MusePlugin } from "#core/plugins";
|
import { loadBinding } from "./binding";
|
||||||
import { MuseError } from "#errors";
|
import { version } from "../package.json";
|
||||||
import { version } from "../../package.json";
|
import type { Binding, CLIArguments } from "./types";
|
||||||
|
|
||||||
interface CLIFlags {
|
|
||||||
help: boolean;
|
|
||||||
verbose: boolean;
|
|
||||||
stdout: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Loggers {
|
interface Loggers {
|
||||||
log: (msg: string) => void;
|
log: (msg: string) => void;
|
||||||
|
@ -42,7 +36,7 @@ Options:
|
||||||
*
|
*
|
||||||
* @throws {CLIError} if the arguments are invalid
|
* @throws {CLIError} if the arguments are invalid
|
||||||
*/
|
*/
|
||||||
function parseCLIArguments(argv: string[]): [string, CLIFlags] {
|
export function parseCLIArguments(argv: string[]): CLIArguments {
|
||||||
const { values: options, positionals: args } = parseArgs({
|
const { values: options, positionals: args } = parseArgs({
|
||||||
args: argv,
|
args: argv,
|
||||||
options: {
|
options: {
|
||||||
|
@ -66,55 +60,59 @@ function parseCLIArguments(argv: string[]): [string, CLIFlags] {
|
||||||
allowPositionals: true,
|
allowPositionals: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return [args[0] ?? "./", options];
|
return {
|
||||||
|
inputFilePath: args[0] ?? "./binding.yaml",
|
||||||
|
flags: {
|
||||||
|
help: options.help,
|
||||||
|
verbose: options.verbose,
|
||||||
|
stdout: options.stdout,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStepLogLine(plugin: MusePlugin): string {
|
|
||||||
const { name, description } = plugin;
|
|
||||||
let processorLogLine = chalk.bold(`↪ ${name}`);
|
|
||||||
|
|
||||||
if (description && description.length > 0) {
|
|
||||||
processorLogLine += chalk.dim(` (${description})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return processorLogLine;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processBinding(
|
async function processBinding(
|
||||||
inputFilePath: string,
|
{ inputFilePath, flags }: CLIArguments,
|
||||||
flags: CLIFlags,
|
|
||||||
{ log, verbose }: Loggers,
|
{ log, verbose }: Loggers,
|
||||||
) {
|
) {
|
||||||
// Load the binding
|
// Load the binding
|
||||||
let binding = await loadBinding(inputFilePath);
|
const binding = await loadBinding(inputFilePath);
|
||||||
|
|
||||||
verbose(`Binding ${binding.bindingPath}`);
|
verbose(`Binding ${binding.bindingPath}`);
|
||||||
|
|
||||||
const entryCount = binding.entries.length;
|
const stepWord = binding.processors.length === 1 ? "step" : "steps";
|
||||||
const stepCount = binding.plugins.length;
|
log(
|
||||||
const stepWord = stepCount === 1 ? "step" : "steps";
|
`Processing ${binding.entries.length} entries with ${binding.processors.length} ${stepWord}`,
|
||||||
log(`Processing ${entryCount} entries with ${stepCount} ${stepWord}`);
|
);
|
||||||
|
|
||||||
const processStart = performance.now();
|
const processStart = performance.now();
|
||||||
|
|
||||||
// Run the data through relevant plugins
|
// Run the data through all processors
|
||||||
for (const plugin of binding.plugins) {
|
const processedSteps: Binding[] = [binding];
|
||||||
log(getStepLogLine(plugin));
|
for (const processor of binding.processors) {
|
||||||
const { step } = plugin;
|
const lastStep = processedSteps[processedSteps.length - 1];
|
||||||
binding = await step(binding);
|
|
||||||
|
const { process, name } = processor;
|
||||||
|
|
||||||
|
log(chalk.bold(`↪ ${name}`) + chalk.dim(` (${processor.description})`));
|
||||||
|
const thisStep = await process(lastStep, binding.options, flags);
|
||||||
|
processedSteps.push(thisStep);
|
||||||
}
|
}
|
||||||
|
|
||||||
const processEnd = performance.now();
|
const processEnd = performance.now();
|
||||||
const processTime = ((processEnd - processStart) / 1000).toFixed(2);
|
const processTime = ((processEnd - processStart) / 1000).toFixed(2);
|
||||||
verbose(`Processing completed in ${processTime}s`);
|
verbose(`Processing completed in ${processTime}s`);
|
||||||
|
|
||||||
|
const finalState = processedSteps[processedSteps.length - 1];
|
||||||
|
const serialized = JSON.stringify(finalState.entries, null, 2);
|
||||||
|
|
||||||
if (flags.stdout) {
|
if (flags.stdout) {
|
||||||
console.log(JSON.stringify(binding, null, 2));
|
console.log(serialized);
|
||||||
}
|
}
|
||||||
return ExitCode.Success;
|
return ExitCode.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<number> {
|
async function main(): Promise<number> {
|
||||||
const [inputFilePath, flags] = parseCLIArguments(Bun.argv.slice(2));
|
const cliArguments = parseCLIArguments(Bun.argv.slice(2));
|
||||||
|
const { flags } = cliArguments;
|
||||||
|
|
||||||
// If --help is specified, print usage and exit
|
// If --help is specified, print usage and exit
|
||||||
if (flags.help) {
|
if (flags.help) {
|
||||||
|
@ -129,7 +127,7 @@ async function main(): Promise<number> {
|
||||||
}
|
}
|
||||||
: () => {};
|
: () => {};
|
||||||
|
|
||||||
const lastProcessResult = await processBinding(inputFilePath, flags, {
|
const lastProcessResult = await processBinding(cliArguments, {
|
||||||
log: logFn,
|
log: logFn,
|
||||||
verbose: verboseFn,
|
verbose: verboseFn,
|
||||||
});
|
});
|
|
@ -1,145 +0,0 @@
|
||||||
import path, { dirname } from "node:path";
|
|
||||||
import { z } from "zod";
|
|
||||||
import type { UnknownRecord } from "#core/records";
|
|
||||||
import { MuseError, MuseFileNotFoundError } from "#errors";
|
|
||||||
import { resolveFilePath } from "#util";
|
|
||||||
import { type MusePlugin, loadPlugin } from "./plugins";
|
|
||||||
import {
|
|
||||||
type MuseEntry,
|
|
||||||
type MuseSource,
|
|
||||||
SourceSchema,
|
|
||||||
loadFromSource,
|
|
||||||
parseMuseFile,
|
|
||||||
sourceShorthandToSource,
|
|
||||||
} from "./sources";
|
|
||||||
|
|
||||||
// Function to parse an unknown object into parts of a binding
|
|
||||||
const BindingSchema = z.object({
|
|
||||||
contentKey: z.string().default("content"),
|
|
||||||
sources: z.array(SourceSchema).default([
|
|
||||||
{
|
|
||||||
file: "./**/*.{json,yaml,toml,md}",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
options: z.record(z.string(), z.any()).default({}),
|
|
||||||
plugins: z.array(z.string()).default([]),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The core object of a Muse binding
|
|
||||||
* Contains information about the binding file, the configuration of
|
|
||||||
* the project, and a list of entries to be processed
|
|
||||||
*/
|
|
||||||
export interface Binding<MetaShape = UnknownRecord> {
|
|
||||||
/** Information about the sources used to load entries */
|
|
||||||
readonly sources: MuseSource[];
|
|
||||||
|
|
||||||
/** The full file path to the binding file */
|
|
||||||
readonly bindingPath: string;
|
|
||||||
|
|
||||||
/** The key used to for default content in unstructured files */
|
|
||||||
readonly contentKey: string;
|
|
||||||
|
|
||||||
/** All entries loaded from the binding file */
|
|
||||||
readonly entries: MuseEntry[];
|
|
||||||
|
|
||||||
/** A list of processors to be applied to the entries */
|
|
||||||
readonly plugins: MusePlugin[];
|
|
||||||
|
|
||||||
/** Arbitrary metadata for processors to use to cache project data */
|
|
||||||
readonly meta: MetaShape;
|
|
||||||
|
|
||||||
/** Configuration options for Muse and all processors */
|
|
||||||
readonly options: UnknownRecord;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given a path, find the binding file
|
|
||||||
* If the path is to a directory, check for a binding inside
|
|
||||||
* Check order: binding.json, binding.yaml, binding.toml, binding.md
|
|
||||||
* Otherwise throw an error
|
|
||||||
*
|
|
||||||
* @param bindingPath - The path to the binding file
|
|
||||||
* @returns The absolute resolved path to the binding file
|
|
||||||
*/
|
|
||||||
async function resolveBindingPath(bindingPath: string): Promise<string> {
|
|
||||||
const DEFAULT_BINDING_FILES = [
|
|
||||||
"binding.json",
|
|
||||||
"binding.yaml",
|
|
||||||
"binding.toml",
|
|
||||||
"binding.md",
|
|
||||||
] as const;
|
|
||||||
const inputLocation = Bun.file(bindingPath);
|
|
||||||
|
|
||||||
// If it is a directory, try for the four main supported types
|
|
||||||
const stat = await inputLocation.stat();
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
for (const bindingFile of DEFAULT_BINDING_FILES) {
|
|
||||||
const filePath = path.resolve(bindingPath, bindingFile);
|
|
||||||
const resolvedPath = await resolveFilePath(filePath);
|
|
||||||
if (resolvedPath) {
|
|
||||||
return resolvedPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new MuseFileNotFoundError(bindingPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not a directory, check if its a file that exists
|
|
||||||
const exists = await inputLocation.exists();
|
|
||||||
|
|
||||||
if (!exists) {
|
|
||||||
throw new MuseFileNotFoundError(bindingPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it is a file, return the path
|
|
||||||
return path.resolve(bindingPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadBinding(initialPath: string): Promise<Binding> {
|
|
||||||
// Resolve the file location if possible
|
|
||||||
const bindingPath = await resolveBindingPath(initialPath);
|
|
||||||
const bindingDirname = dirname(bindingPath);
|
|
||||||
const loadedBindingData = await parseMuseFile(bindingPath, {
|
|
||||||
contentKey: "content",
|
|
||||||
cwd: bindingDirname,
|
|
||||||
ignore: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (loadedBindingData.length === 0) {
|
|
||||||
throw new MuseError("No entries found in binding file");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load and parse the Binding YAML content
|
|
||||||
const parsedBinding = BindingSchema.parse(loadedBindingData[0].data);
|
|
||||||
|
|
||||||
const sourceOptions = {
|
|
||||||
cwd: bindingDirname,
|
|
||||||
contentKey: parsedBinding.contentKey,
|
|
||||||
ignore: [bindingPath],
|
|
||||||
};
|
|
||||||
|
|
||||||
const sources = parsedBinding.sources.map(sourceShorthandToSource);
|
|
||||||
|
|
||||||
const entries: MuseEntry[] = [];
|
|
||||||
|
|
||||||
for (const source of sources) {
|
|
||||||
const entriesFromSource = await loadFromSource(source, sourceOptions);
|
|
||||||
entries.push(...entriesFromSource);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load and check plugins
|
|
||||||
const plugins: MusePlugin[] = await Promise.all(
|
|
||||||
parsedBinding.plugins.map(loadPlugin.bind(null, bindingDirname)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sources,
|
|
||||||
bindingPath,
|
|
||||||
contentKey: parsedBinding.contentKey,
|
|
||||||
entries,
|
|
||||||
options: parsedBinding.options,
|
|
||||||
plugins,
|
|
||||||
meta: {},
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,34 +0,0 @@
|
||||||
import { basename, dirname, extname, normalize } from "node:path";
|
|
||||||
import { MuseFileNotFoundError } from "#errors";
|
|
||||||
|
|
||||||
export interface LoadedFile {
|
|
||||||
content: string;
|
|
||||||
filePath: string;
|
|
||||||
fileType: string;
|
|
||||||
dirname: string;
|
|
||||||
basename: string;
|
|
||||||
mimeType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadFileContent(filePath: string): Promise<LoadedFile> {
|
|
||||||
const normalizedPath = normalize(filePath);
|
|
||||||
const file = Bun.file(normalizedPath);
|
|
||||||
|
|
||||||
const exists = await file.exists();
|
|
||||||
if (!exists) {
|
|
||||||
throw new MuseFileNotFoundError(normalizedPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const filecontent = await file.text();
|
|
||||||
|
|
||||||
const extension = extname(normalizedPath);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: filecontent,
|
|
||||||
filePath: normalizedPath,
|
|
||||||
fileType: extension.slice(1),
|
|
||||||
dirname: dirname(normalizedPath),
|
|
||||||
basename: basename(normalizedPath, extension),
|
|
||||||
mimeType: file.type,
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
import path from "node:path";
|
|
||||||
import { MuseError, MusePluginError } from "#errors";
|
|
||||||
import type { Binding } from "./binding";
|
|
||||||
|
|
||||||
export type MuseStepFn = (binding: Binding) => Promise<Binding>;
|
|
||||||
|
|
||||||
export interface MusePlugin {
|
|
||||||
readonly filePath: string;
|
|
||||||
readonly name: string;
|
|
||||||
readonly description: string;
|
|
||||||
readonly version: string;
|
|
||||||
readonly step: MuseStepFn;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadPlugin(
|
|
||||||
bindingDirname: string,
|
|
||||||
pluginPath: string,
|
|
||||||
): Promise<MusePlugin> {
|
|
||||||
// Resolve local paths, use URLs as-is
|
|
||||||
const resolvedProcessorPath = pluginPath.startsWith("http")
|
|
||||||
? pluginPath
|
|
||||||
: path.resolve(bindingDirname, pluginPath);
|
|
||||||
const { step, name, description, version } = await import(
|
|
||||||
resolvedProcessorPath
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!step || typeof step !== "function") {
|
|
||||||
throw new MuseError(
|
|
||||||
`Processor at ${pluginPath} does not export a step() function`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name && typeof name !== "string") {
|
|
||||||
throw new MusePluginError(pluginPath, "Plugin name is not a string.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (description && typeof description !== "string") {
|
|
||||||
throw new MusePluginError(pluginPath, "Plugin description is not a string");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (version && typeof version !== "string") {
|
|
||||||
throw new MusePluginError(pluginPath, "Plugin version is not a string");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
filePath: resolvedProcessorPath,
|
|
||||||
step: step as MuseStepFn,
|
|
||||||
name: String(name ?? pluginPath),
|
|
||||||
description: String(description ?? ""),
|
|
||||||
version: String(version ?? "0.0.0"),
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,107 +0,0 @@
|
||||||
import EMDY from "@endeavorance/emdy";
|
|
||||||
import TOML from "smol-toml";
|
|
||||||
import YAML from "yaml";
|
|
||||||
import type { LoadedFile } from "./files";
|
|
||||||
|
|
||||||
export type UnknownRecord = Record<string, unknown>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given an unknown shape, ensure it is an object or array of objects,
|
|
||||||
* then return it as an array of objects.
|
|
||||||
*
|
|
||||||
* @param val - The unknown value to format
|
|
||||||
* @returns - An array of objects
|
|
||||||
*/
|
|
||||||
function formatEntries(val: unknown): UnknownRecord[] {
|
|
||||||
if (Array.isArray(val) && val.every((el) => typeof el === "object")) {
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof val === "object" && val !== null) {
|
|
||||||
return [val as UnknownRecord];
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`Invalid data format. Entry files must define an object or array of objects, but found "${val}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse one or more YAML documents from a string
|
|
||||||
*
|
|
||||||
* @param text - The YAML string to parse
|
|
||||||
* @returns - An array of parsed objects
|
|
||||||
*/
|
|
||||||
export function parseYAMLEntries(text: string): UnknownRecord[] {
|
|
||||||
const parsedDocs = YAML.parseAllDocuments(text);
|
|
||||||
|
|
||||||
if (parsedDocs.some((doc) => doc.toJS() === null)) {
|
|
||||||
throw new Error("Encountered NULL resource");
|
|
||||||
}
|
|
||||||
|
|
||||||
const errors = parsedDocs.flatMap((doc) => doc.errors);
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Error parsing YAML resource: ${errors.map((e) => e.message).join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const collection: UnknownRecord[] = parsedDocs.map((doc) => doc.toJS());
|
|
||||||
return collection;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse one or more JSON documents from a string
|
|
||||||
*
|
|
||||||
* @param text - The JSON string to parse
|
|
||||||
* @returns - An array of parsed objects
|
|
||||||
*/
|
|
||||||
export function parseJSONEntries(text: string): UnknownRecord[] {
|
|
||||||
return formatEntries(JSON.parse(text));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse one or more TOML documents from a string
|
|
||||||
*
|
|
||||||
* @param text - The TOML string to parse
|
|
||||||
* @returns - An array of parsed objects
|
|
||||||
*/
|
|
||||||
export function parseTOMLEntries(text: string): UnknownRecord[] {
|
|
||||||
return formatEntries(TOML.parse(text));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse one or more Markdown documents from a string
|
|
||||||
*
|
|
||||||
* @param text - The Markdown string to parse
|
|
||||||
* @returns - An array of parsed objects
|
|
||||||
*/
|
|
||||||
export function parseMarkdownEntry(
|
|
||||||
text: string,
|
|
||||||
contentKey: string,
|
|
||||||
): UnknownRecord[] {
|
|
||||||
return formatEntries(EMDY.parse(text, contentKey));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function autoParseEntry(loadedFile: LoadedFile, contentKey: string) {
|
|
||||||
const { fileType, content } = loadedFile;
|
|
||||||
|
|
||||||
if (fileType === "md") {
|
|
||||||
return parseMarkdownEntry(content, contentKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileType === "yaml") {
|
|
||||||
return parseYAMLEntries(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileType === "json") {
|
|
||||||
return parseJSONEntries(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileType === "toml") {
|
|
||||||
return parseTOMLEntries(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unsupported file type: ${fileType}`);
|
|
||||||
}
|
|
|
@ -1,172 +0,0 @@
|
||||||
import { Glob } from "bun";
|
|
||||||
import { type LoadedFile, loadFileContent } from "./files";
|
|
||||||
import { type UnknownRecord, autoParseEntry } from "./records";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { MuseError } from "#errors";
|
|
||||||
|
|
||||||
export const SourceSchema = z.union([
|
|
||||||
z.object({
|
|
||||||
file: z.string(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
url: z.string().url(),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export type SourceShorthand = z.infer<typeof SourceSchema>;
|
|
||||||
|
|
||||||
export function sourceShorthandToSource(
|
|
||||||
shorthand: SourceShorthand,
|
|
||||||
): MuseSource {
|
|
||||||
if ("file" in shorthand) {
|
|
||||||
return {
|
|
||||||
location: shorthand.file,
|
|
||||||
type: "file",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("url" in shorthand) {
|
|
||||||
return {
|
|
||||||
location: shorthand.url,
|
|
||||||
type: "url",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new MuseError("Invalid source shorthand");
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SourceType = "file" | "url";
|
|
||||||
|
|
||||||
/** A descriptor of a location to load into a Muse binding */
|
|
||||||
export interface MuseSource {
|
|
||||||
location: string;
|
|
||||||
type: SourceType;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SourceOptions {
|
|
||||||
cwd: string;
|
|
||||||
contentKey: string;
|
|
||||||
ignore: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A single loaded entry from a Muse source */
|
|
||||||
export interface MuseEntry<MetaShape = UnknownRecord> {
|
|
||||||
_raw: string;
|
|
||||||
location: string;
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
meta: MetaShape;
|
|
||||||
source: MuseSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function parseMuseFile(
|
|
||||||
rawFilePath: string,
|
|
||||||
{ contentKey = "content" }: SourceOptions,
|
|
||||||
): Promise<MuseEntry[]> {
|
|
||||||
const file = await loadFileContent(rawFilePath);
|
|
||||||
const entries = autoParseEntry(file, contentKey);
|
|
||||||
|
|
||||||
const partial = {
|
|
||||||
_raw: file.content,
|
|
||||||
source: {
|
|
||||||
location: file.filePath,
|
|
||||||
type: "file" as SourceType,
|
|
||||||
},
|
|
||||||
location: file.filePath,
|
|
||||||
meta: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
return entries.map((data) => ({
|
|
||||||
...partial,
|
|
||||||
data,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFromFileSource(
|
|
||||||
source: MuseSource,
|
|
||||||
options: SourceOptions,
|
|
||||||
): Promise<MuseEntry[]> {
|
|
||||||
const paths = Array.from(
|
|
||||||
new Glob(source.location).scanSync({
|
|
||||||
cwd: options.cwd,
|
|
||||||
absolute: true,
|
|
||||||
followSymlinks: true,
|
|
||||||
onlyFiles: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredPaths = paths.filter((path) => !options.ignore.includes(path));
|
|
||||||
|
|
||||||
const entries: MuseEntry[] = [];
|
|
||||||
for (const filePath of filteredPaths) {
|
|
||||||
const fileEntries = await parseMuseFile(filePath, options);
|
|
||||||
entries.push(...fileEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFileExtensionFromURL(url: string): string | null {
|
|
||||||
const parsedUrl = new URL(url);
|
|
||||||
|
|
||||||
const pathname = parsedUrl.pathname;
|
|
||||||
const filename = pathname.substring(pathname.lastIndexOf("/") + 1);
|
|
||||||
const extension = filename.substring(filename.lastIndexOf(".") + 1);
|
|
||||||
|
|
||||||
return extension === filename ? null : extension;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFromURLSource(
|
|
||||||
source: MuseSource,
|
|
||||||
options: SourceOptions,
|
|
||||||
): Promise<MuseEntry[]> {
|
|
||||||
const { contentKey = "content" } = options;
|
|
||||||
const response = await fetch(source.location);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch URL: ${source.location}`);
|
|
||||||
}
|
|
||||||
const content = await response.text();
|
|
||||||
const mimeType = response.headers.get("Content-Type") || "unknown";
|
|
||||||
|
|
||||||
const parseType = getFileExtensionFromURL(source.location) ?? "unknown";
|
|
||||||
|
|
||||||
const loadedFile: LoadedFile = {
|
|
||||||
content,
|
|
||||||
filePath: source.location,
|
|
||||||
fileType: parseType,
|
|
||||||
dirname: "",
|
|
||||||
basename: "",
|
|
||||||
mimeType,
|
|
||||||
};
|
|
||||||
|
|
||||||
const entries = autoParseEntry(loadedFile, contentKey);
|
|
||||||
|
|
||||||
const partial = {
|
|
||||||
_raw: content,
|
|
||||||
source: {
|
|
||||||
location: source.location,
|
|
||||||
type: "url" as SourceType,
|
|
||||||
},
|
|
||||||
location: source.location,
|
|
||||||
meta: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
return entries.map((data) => ({
|
|
||||||
...partial,
|
|
||||||
data,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadFromSource(
|
|
||||||
source: MuseSource,
|
|
||||||
options: SourceOptions,
|
|
||||||
): Promise<MuseEntry[]> {
|
|
||||||
switch (source.type) {
|
|
||||||
case "file":
|
|
||||||
return loadFromFileSource(source, options);
|
|
||||||
case "url":
|
|
||||||
return loadFromURLSource(source, options);
|
|
||||||
default:
|
|
||||||
throw new Error(`Unsupported source type: ${source.type}`);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,39 +1,28 @@
|
||||||
export class MuseError extends Error {
|
export class MuseError extends Error {
|
||||||
fmt(): string {
|
fmt(): string {
|
||||||
return `-- ERROR -- \nDetails: ${this.message}`;
|
return this.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MusePluginError extends MuseError {
|
export class FileError extends MuseError {
|
||||||
plugin: string;
|
|
||||||
|
|
||||||
constructor(message: string, plugin: string) {
|
|
||||||
super(message);
|
|
||||||
this.plugin = plugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt(): string {
|
|
||||||
return `-- PLUGIN ERROR --\Plugin: ${this.plugin}\nDetails: ${this.message}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MuseFileError extends MuseError {
|
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
details?: string;
|
||||||
|
|
||||||
constructor(message: string, filePath: string) {
|
constructor(message: string, filePath: string, details?: string) {
|
||||||
super(message);
|
super(message);
|
||||||
this.filePath = filePath;
|
this.filePath = filePath;
|
||||||
|
this.details = details;
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt(): string {
|
fmt(): string {
|
||||||
return `-- FILE ERROR --\nFile Path: ${this.filePath}\nDetails: ${this.message}`;
|
return `-- ${this.message} --\nFile Path: ${this.filePath}\nDetails: ${this.details}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MuseFileNotFoundError extends MuseFileError {
|
export class FileNotFoundError extends FileError {
|
||||||
constructor(filePath: string) {
|
constructor(filePath: string) {
|
||||||
super("File not found", filePath);
|
super("File not found", filePath);
|
||||||
this.message = "File does not exist";
|
this.message = "File not found";
|
||||||
this.filePath = filePath;
|
this.filePath = filePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,2 @@
|
||||||
// Content exports
|
export type * from "./types";
|
||||||
export * from "#errors";
|
|
||||||
|
|
||||||
// Constants
|
|
||||||
export const DEFAULT_CONTENT_KEY = "content";
|
export const DEFAULT_CONTENT_KEY = "content";
|
||||||
|
|
||||||
// Types
|
|
||||||
export type * from "#core/binding";
|
|
||||||
export type * from "#core/plugins";
|
|
||||||
export type * from "#core/sources";
|
|
||||||
export type * from "#core/records";
|
|
||||||
export type * from "#core/files";
|
|
||||||
|
|
105
src/parse.ts
Normal file
105
src/parse.ts
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
import { normalize, extname } from "node:path";
|
||||||
|
import YAML from "yaml";
|
||||||
|
import TOML from "smol-toml";
|
||||||
|
import EMDY from "@endeavorance/emdy";
|
||||||
|
import type { MuseEntry, UnknownRecord } from "./types";
|
||||||
|
|
||||||
|
function parseYAMLEntries(text: string): UnknownRecord[] {
|
||||||
|
const parsedDocs = YAML.parseAllDocuments(text);
|
||||||
|
|
||||||
|
if (parsedDocs.some((doc) => doc.toJS() === null)) {
|
||||||
|
throw new Error("Encountered NULL resource");
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = parsedDocs.flatMap((doc) => doc.errors);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Error parsing YAML resource: ${errors.map((e) => e.message).join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const collection: UnknownRecord[] = parsedDocs.map((doc) => doc.toJS());
|
||||||
|
return collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJSONEntries(text: string): UnknownRecord[] {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof parsed === "object") {
|
||||||
|
return [parsed];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("JSON resource must be an object or an array of objects");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTOMLEntries(text: string): UnknownRecord[] {
|
||||||
|
const parsed = TOML.parse(text);
|
||||||
|
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof parsed === "object") {
|
||||||
|
return [parsed];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("TOML resource must be an object or an array of objects");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParseMuseFileOptions {
|
||||||
|
contentKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseMuseFile(
|
||||||
|
rawFilePath: string,
|
||||||
|
{ contentKey = "content" }: ParseMuseFileOptions = {},
|
||||||
|
): Promise<MuseEntry[]> {
|
||||||
|
const filePath = normalize(rawFilePath);
|
||||||
|
const file = Bun.file(filePath);
|
||||||
|
const fileType = extname(filePath).slice(1);
|
||||||
|
const rawFileContent = await file.text();
|
||||||
|
|
||||||
|
const partial = {
|
||||||
|
_raw: rawFileContent,
|
||||||
|
filePath,
|
||||||
|
meta: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fileType === "md") {
|
||||||
|
const parsed = EMDY.parse(rawFileContent, contentKey);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...partial,
|
||||||
|
data: parsed,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileType === "yaml") {
|
||||||
|
return parseYAMLEntries(rawFileContent).map((data) => ({
|
||||||
|
...partial,
|
||||||
|
data,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileType === "json") {
|
||||||
|
return parseJSONEntries(rawFileContent).map((data) => ({
|
||||||
|
...partial,
|
||||||
|
data,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileType === "toml") {
|
||||||
|
return parseTOMLEntries(rawFileContent).map((data) => ({
|
||||||
|
...partial,
|
||||||
|
data,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported file type: ${fileType}`);
|
||||||
|
}
|
|
@ -1,40 +0,0 @@
|
||||||
const rx_any = /./;
|
|
||||||
const rx_http = /^https?:\/\//;
|
|
||||||
const rx_relative_path = /^\.\.?\//;
|
|
||||||
const rx_absolute_path = /^\//;
|
|
||||||
|
|
||||||
async function load_http_module(href) {
|
|
||||||
return fetch(href).then((response) =>
|
|
||||||
response
|
|
||||||
.text()
|
|
||||||
.then((text) =>
|
|
||||||
response.ok
|
|
||||||
? { contents: text, loader: "js" }
|
|
||||||
: Promise.reject(
|
|
||||||
new Error(`Failed to load module '${href}'': ${text}`),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Bun.plugin({
|
|
||||||
name: "http_imports",
|
|
||||||
setup(build) {
|
|
||||||
build.onResolve({ filter: rx_relative_path }, (args) => {
|
|
||||||
if (rx_http.test(args.importer)) {
|
|
||||||
return { path: new URL(args.path, args.importer).href };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
build.onResolve({ filter: rx_absolute_path }, (args) => {
|
|
||||||
if (rx_http.test(args.importer)) {
|
|
||||||
return { path: new URL(args.path, args.importer).href };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
build.onLoad({ filter: rx_any, namespace: "http" }, (args) =>
|
|
||||||
load_http_module(`http:${args.path}`),
|
|
||||||
);
|
|
||||||
build.onLoad({ filter: rx_any, namespace: "https" }, (args) =>
|
|
||||||
load_http_module(`https:${args.path}`),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
55
src/types.ts
Normal file
55
src/types.ts
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
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>;
|
|
@ -26,22 +26,11 @@
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
"baseUrl": "./src",
|
"baseUrl": "./src",
|
||||||
"paths": {
|
"paths": {},
|
||||||
"#core/*": [
|
|
||||||
"./core/*.ts"
|
|
||||||
],
|
|
||||||
"#util": [
|
|
||||||
"./util.ts"
|
|
||||||
],
|
|
||||||
"#errors": [
|
|
||||||
"./errors.ts"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.ts",
|
"src/**/*.ts",
|
||||||
"src/**/*.tsx",
|
"src/**/*.tsx",
|
||||||
"src/preload/http-plugin.js",
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue