Update plugin shape

This commit is contained in:
Endeavorance 2025-04-03 14:55:47 -04:00
parent 504c1e6f3c
commit ae1b9e262e
12 changed files with 181 additions and 149 deletions

View file

@ -1,14 +1,19 @@
export const name = "Announce Slam Dunks";
export const description = "Get hype when a file has slam dunks";
export const process = (binding, { dunks }) => {
export const step = ({ entries, options, ...rest }) => {
const { dunks } = options;
const slamDunkKey = dunks?.key ?? "slamDunks";
for (const entry of binding.entries) {
for (const entry of entries) {
if (slamDunkKey in entry.data) {
console.log(`Slam dunk!`);
}
}
return binding;
return {
entries,
options,
...rest,
};
};

View file

@ -1,6 +1,4 @@
export const name = "Scream";
export function process(binding) {
export function step(binding) {
const modifiedEntries = binding.entries.map(entry => {
if (binding.markdownKey in entry.data) {
entry.data = {

View file

@ -1,6 +1,7 @@
import { parseArgs } from "node:util";
import chalk from "chalk";
import { type Binding, loadBinding } from "#core/binding";
import { loadBinding } from "#core/binding";
import type { MusePlugin } from "#core/plugins";
import { MuseError } from "#errors";
import { version } from "../../package.json";
@ -68,50 +69,46 @@ function parseCLIArguments(argv: string[]): [string, CLIFlags] {
return [args[0] ?? "./", options];
}
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(
inputFilePath: string,
flags: CLIFlags,
{ log, verbose }: Loggers,
) {
// Load the binding
const binding = await loadBinding(inputFilePath);
let binding = await loadBinding(inputFilePath);
verbose(`Binding ${binding.bindingPath}`);
const stepWord = binding.processors.length === 1 ? "step" : "steps";
log(
`Processing ${binding.entries.length} entries with ${binding.processors.length} ${stepWord}`,
);
const entryCount = binding.entries.length;
const stepCount = binding.plugins.length;
const stepWord = stepCount === 1 ? "step" : "steps";
log(`Processing ${entryCount} entries with ${stepCount} ${stepWord}`);
const processStart = performance.now();
// Run the data through all processors
const processedSteps: Binding[] = [binding];
for (const processor of binding.processors) {
const lastStep = processedSteps[processedSteps.length - 1];
const { process, name, description } = processor;
let processorLogLine = chalk.bold(`${name}`);
if (description && description.length > 0) {
processorLogLine += chalk.dim(` (${description})`);
}
log(processorLogLine);
const thisStep = await process(lastStep, binding.options);
processedSteps.push(thisStep);
// Run the data through relevant plugins
for (const plugin of binding.plugins) {
log(getStepLogLine(plugin));
const { step } = plugin;
binding = await step(binding);
}
const processEnd = performance.now();
const processTime = ((processEnd - processStart) / 1000).toFixed(2);
verbose(`Processing completed in ${processTime}s`);
const finalState = processedSteps[processedSteps.length - 1];
const serialized = JSON.stringify(finalState.entries, null, 2);
if (flags.stdout) {
console.log(serialized);
console.log(JSON.stringify(binding, null, 2));
}
return ExitCode.Success;
}

View file

@ -2,10 +2,10 @@ import path, { dirname } from "node:path";
import { Glob } from "bun";
import { z } from "zod";
import { MuseError, MuseFileNotFoundError } from "#errors";
import type { UnknownRecord } from "#types";
import { resolveFilePath } from "#util";
import { type MuseEntry, parseMuseFile } from "./parse";
import { type MuseProcessor, loadProcessor } from "./processor";
import { type MusePlugin, loadPlugin } from "./plugins";
import type { UnknownRecord } from "./types";
// Function to parse an unknown object into parts of a binding
export const BindingSchema = z.object({
@ -37,7 +37,7 @@ export interface Binding<MetaShape = UnknownRecord> {
readonly entries: MuseEntry[];
/** A list of processors to be applied to the entries */
readonly processors: MuseProcessor[];
readonly plugins: MusePlugin[];
/** Arbitrary metadata for processors to use to cache project data */
readonly meta: MetaShape;
@ -75,20 +75,14 @@ async function resolveBindingPath(bindingPath: string): Promise<string> {
}
}
throw new MuseFileNotFoundError(
bindingPath,
"Failed to find a binding file at the provided directory.",
);
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,
"Provided binding path does not exist.",
);
throw new MuseFileNotFoundError(bindingPath);
}
// If it is a file, return the path
@ -133,9 +127,9 @@ export async function loadBinding(initialPath: string): Promise<Binding> {
)
).flat();
// Load and check processors
const processors: MuseProcessor[] = await Promise.all(
parsedBinding.processors.map(loadProcessor.bind(null, bindingDirname)),
// Load and check plugins
const plugins: MusePlugin[] = await Promise.all(
parsedBinding.processors.map(loadPlugin.bind(null, bindingDirname)),
);
return {
@ -144,7 +138,7 @@ export async function loadBinding(initialPath: string): Promise<Binding> {
contentKey: parsedBinding.contentKey,
entries,
options: parsedBinding.options,
processors: processors,
plugins,
meta: {},
};
}

32
src/core/files.ts Normal file
View file

@ -0,0 +1,32 @@
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;
}
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),
};
}

View file

@ -1,9 +1,8 @@
import { extname, normalize } from "node:path";
import EMDY from "@endeavorance/emdy";
import TOML from "smol-toml";
import YAML from "yaml";
import type { UnknownRecord } from "#types";
import { MuseFileNotFoundError } from "#errors";
import { loadFileContent } from "./files";
import type { UnknownRecord } from "./types";
export interface MuseEntry<MetaShape = UnknownRecord> {
_raw: string;
@ -12,6 +11,20 @@ export interface MuseEntry<MetaShape = UnknownRecord> {
meta: MetaShape;
}
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}"`,
);
}
function parseYAMLEntries(text: string): UnknownRecord[] {
const parsedDocs = YAML.parseAllDocuments(text);
@ -32,31 +45,15 @@ function parseYAMLEntries(text: string): UnknownRecord[] {
}
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");
return formatEntries(JSON.parse(text));
}
function parseTOMLEntries(text: string): UnknownRecord[] {
const parsed = TOML.parse(text);
return formatEntries(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");
function parseMarkdownEntry(text: string, contentKey: string): UnknownRecord[] {
return formatEntries(EMDY.parse(text, contentKey));
}
interface ParseMuseFileOptions {
@ -67,49 +64,37 @@ 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 fileExists = await file.exists();
if (!fileExists) {
throw new MuseFileNotFoundError(filePath, "Failed to load source file.");
}
const rawFileContent = await file.text();
const { content, filePath, fileType } = await loadFileContent(rawFilePath);
const partial = {
_raw: rawFileContent,
_raw: content,
filePath,
meta: {},
};
if (fileType === "md") {
const parsed = EMDY.parse(rawFileContent, contentKey);
return [
{
...partial,
data: parsed,
},
];
return parseMarkdownEntry(content, contentKey).map((data) => ({
...partial,
data,
}));
}
if (fileType === "yaml") {
return parseYAMLEntries(rawFileContent).map((data) => ({
return parseYAMLEntries(content).map((data) => ({
...partial,
data,
}));
}
if (fileType === "json") {
return parseJSONEntries(rawFileContent).map((data) => ({
return parseJSONEntries(content).map((data) => ({
...partial,
data,
}));
}
if (fileType === "toml") {
return parseTOMLEntries(rawFileContent).map((data) => ({
return parseTOMLEntries(content).map((data) => ({
...partial,
data,
}));

52
src/core/plugins.ts Normal file
View file

@ -0,0 +1,52 @@
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,42 +0,0 @@
import path from "node:path";
import { MuseError } from "#errors";
import type { Binding } from "./binding";
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 async function loadProcessor(
bindingDirname: string,
processorPath: string,
): Promise<MuseProcessor> {
// Resolve local paths, use URLs as-is
const resolvedProcessorPath = processorPath.startsWith("http")
? processorPath
: 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 ?? "");
return {
filePath: resolvedProcessorPath,
process: process as MuseProcessor["process"],
name: processorName,
description: processorDescription,
};
}

View file

@ -1,3 +1 @@
import { z } from "zod";
export type UnknownRecord = Record<string, unknown>;

View file

@ -1,30 +1,40 @@
export class MuseError extends Error {
fmt(): string {
return this.message;
return `-- ERROR -- \nDetails: ${this.message}`;
}
}
export class MusePluginError 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;
details?: string;
constructor(message: string, filePath: string, details?: string) {
constructor(message: string, filePath: string) {
super(message);
this.filePath = filePath;
this.details = details;
}
fmt(): string {
return `-- ${this.message} --\nFile Path: ${this.filePath}\nDetails: ${this.details}`;
return `-- FILE ERROR --\nFile Path: ${this.filePath}\nDetails: ${this.message}`;
}
}
export class MuseFileNotFoundError extends MuseFileError {
constructor(filePath: string, details?: string) {
constructor(filePath: string) {
super("File not found", filePath);
this.message = "File not found";
this.message = "File does not exist";
this.filePath = filePath;
this.details = details ?? "No additional information.";
}
}

View file

@ -1,3 +1,9 @@
export type * from "#types";
// Content exports
export * from "#errors";
// Constants
export const DEFAULT_CONTENT_KEY = "content";
// Types
export type * from "#types";
export type { MusePlugin } from "#core/plugins";

View file

@ -33,9 +33,6 @@
"#util": [
"./util.ts"
],
"#types": [
"./types.ts"
],
"#errors": [
"./errors.ts"
]