Compare commits

..

No commits in common. "main" and "muse-gen" have entirely different histories.

20 changed files with 399 additions and 697 deletions

View file

@ -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,
}
};
}
```

View file

@ -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",

View file

@ -1 +0,0 @@
preload = ["./src/preload/http-plugin.js"]

View file

@ -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

View file

@ -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,
};
}; };

View file

@ -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 = {

View file

@ -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
View 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: {},
};
}

View file

@ -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,
}); });

View file

@ -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: {},
};
}

View file

@ -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,
};
}

View file

@ -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"),
};
}

View file

@ -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}`);
}

View file

@ -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}`);
}
}

View file

@ -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;
} }
} }

View file

@ -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
View 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}`);
}

View file

@ -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
View 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>;

View file

@ -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",
] ]
} }