From ae1b9e262e2967516bb6927f0544a25bf6f249f6 Mon Sep 17 00:00:00 2001 From: Endeavorance Date: Thu, 3 Apr 2025 14:55:47 -0400 Subject: [PATCH] Update plugin shape --- demo/main/processors/announce-slam-dunks.js | 11 ++- demo/main/processors/scream.js | 4 +- src/cli/index.ts | 51 +++++++------- src/core/binding.ts | 24 +++---- src/core/files.ts | 32 +++++++++ src/core/parse.ts | 75 +++++++++------------ src/core/plugins.ts | 52 ++++++++++++++ src/core/processor.ts | 42 ------------ src/{ => core}/types.ts | 2 - src/errors.ts | 26 ++++--- src/exports.ts | 8 ++- tsconfig.json | 3 - 12 files changed, 181 insertions(+), 149 deletions(-) create mode 100644 src/core/files.ts create mode 100644 src/core/plugins.ts delete mode 100644 src/core/processor.ts rename src/{ => core}/types.ts (67%) diff --git a/demo/main/processors/announce-slam-dunks.js b/demo/main/processors/announce-slam-dunks.js index 56824e5..e1889a3 100644 --- a/demo/main/processors/announce-slam-dunks.js +++ b/demo/main/processors/announce-slam-dunks.js @@ -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, + }; }; diff --git a/demo/main/processors/scream.js b/demo/main/processors/scream.js index 17e0bac..4a8a629 100644 --- a/demo/main/processors/scream.js +++ b/demo/main/processors/scream.js @@ -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 = { diff --git a/src/cli/index.ts b/src/cli/index.ts index 6e631a0..edb3d9d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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; } diff --git a/src/core/binding.ts b/src/core/binding.ts index eee86b1..b78292d 100644 --- a/src/core/binding.ts +++ b/src/core/binding.ts @@ -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 { 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 { } } - 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 { ) ).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 { contentKey: parsedBinding.contentKey, entries, options: parsedBinding.options, - processors: processors, + plugins, meta: {}, }; } diff --git a/src/core/files.ts b/src/core/files.ts new file mode 100644 index 0000000..a32b877 --- /dev/null +++ b/src/core/files.ts @@ -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 { + 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), + }; +} diff --git a/src/core/parse.ts b/src/core/parse.ts index 128de7f..6617db2 100644 --- a/src/core/parse.ts +++ b/src/core/parse.ts @@ -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 { _raw: string; @@ -12,6 +11,20 @@ export interface MuseEntry { 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 { - 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, })); diff --git a/src/core/plugins.ts b/src/core/plugins.ts new file mode 100644 index 0000000..3f2ffed --- /dev/null +++ b/src/core/plugins.ts @@ -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; + +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 { + // 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"), + }; +} diff --git a/src/core/processor.ts b/src/core/processor.ts deleted file mode 100644 index 1df9753..0000000 --- a/src/core/processor.ts +++ /dev/null @@ -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, -) => Promise; - -export interface MuseProcessor { - readonly filePath: string; - readonly name: string; - readonly description: string; - readonly process: MuseProcessorFn; -} - -export async function loadProcessor( - bindingDirname: string, - processorPath: string, -): Promise { - // 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, - }; -} diff --git a/src/types.ts b/src/core/types.ts similarity index 67% rename from src/types.ts rename to src/core/types.ts index b57ec2f..35ba466 100644 --- a/src/types.ts +++ b/src/core/types.ts @@ -1,3 +1 @@ -import { z } from "zod"; - export type UnknownRecord = Record; diff --git a/src/errors.ts b/src/errors.ts index 9ad54ac..5f43323 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -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."; } } diff --git a/src/exports.ts b/src/exports.ts index 65319af..f520a46 100644 --- a/src/exports.ts +++ b/src/exports.ts @@ -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"; diff --git a/tsconfig.json b/tsconfig.json index 0f25433..a964c9c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,9 +33,6 @@ "#util": [ "./util.ts" ], - "#types": [ - "./types.ts" - ], "#errors": [ "./errors.ts" ]