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 name = "Announce Slam Dunks";
export const description = "Get hype when a file has 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"; const slamDunkKey = dunks?.key ?? "slamDunks";
for (const entry of binding.entries) { for (const entry of entries) {
if (slamDunkKey in entry.data) { if (slamDunkKey in entry.data) {
console.log(`Slam dunk!`); console.log(`Slam dunk!`);
} }
} }
return binding; return {
entries,
options,
...rest,
};
}; };

View file

@ -1,6 +1,4 @@
export const name = "Scream"; export function step(binding) {
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,7 @@
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
import chalk from "chalk"; 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 { MuseError } from "#errors";
import { version } from "../../package.json"; import { version } from "../../package.json";
@ -68,50 +69,46 @@ function parseCLIArguments(argv: string[]): [string, CLIFlags] {
return [args[0] ?? "./", options]; return [args[0] ?? "./", options];
} }
async function processBinding( function getStepLogLine(plugin: MusePlugin): string {
inputFilePath: string, const { name, description } = plugin;
flags: CLIFlags,
{ log, verbose }: Loggers,
) {
// Load the binding
const 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 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}`); let processorLogLine = chalk.bold(`${name}`);
if (description && description.length > 0) { if (description && description.length > 0) {
processorLogLine += chalk.dim(` (${description})`); processorLogLine += chalk.dim(` (${description})`);
} }
log(processorLogLine); return processorLogLine;
const thisStep = await process(lastStep, binding.options); }
processedSteps.push(thisStep);
async function processBinding(
inputFilePath: string,
flags: CLIFlags,
{ log, verbose }: Loggers,
) {
// Load the binding
let binding = await loadBinding(inputFilePath);
verbose(`Binding ${binding.bindingPath}`);
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 relevant plugins
for (const plugin of binding.plugins) {
log(getStepLogLine(plugin));
const { step } = plugin;
binding = await step(binding);
} }
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(serialized); console.log(JSON.stringify(binding, null, 2));
} }
return ExitCode.Success; return ExitCode.Success;
} }

View file

@ -2,10 +2,10 @@ import path, { dirname } from "node:path";
import { Glob } from "bun"; import { Glob } from "bun";
import { z } from "zod"; import { z } from "zod";
import { MuseError, MuseFileNotFoundError } from "#errors"; import { MuseError, MuseFileNotFoundError } from "#errors";
import type { UnknownRecord } from "#types";
import { resolveFilePath } from "#util"; import { resolveFilePath } from "#util";
import { type MuseEntry, parseMuseFile } from "./parse"; 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 // Function to parse an unknown object into parts of a binding
export const BindingSchema = z.object({ export const BindingSchema = z.object({
@ -37,7 +37,7 @@ export interface Binding<MetaShape = UnknownRecord> {
readonly entries: MuseEntry[]; readonly entries: MuseEntry[];
/** A list of processors to be applied to the entries */ /** 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 */ /** Arbitrary metadata for processors to use to cache project data */
readonly meta: MetaShape; readonly meta: MetaShape;
@ -75,20 +75,14 @@ async function resolveBindingPath(bindingPath: string): Promise<string> {
} }
} }
throw new MuseFileNotFoundError( throw new MuseFileNotFoundError(bindingPath);
bindingPath,
"Failed to find a binding file at the provided directory.",
);
} }
// If not a directory, check if its a file that exists // If not a directory, check if its a file that exists
const exists = await inputLocation.exists(); const exists = await inputLocation.exists();
if (!exists) { if (!exists) {
throw new MuseFileNotFoundError( throw new MuseFileNotFoundError(bindingPath);
bindingPath,
"Provided binding path does not exist.",
);
} }
// If it is a file, return the path // If it is a file, return the path
@ -133,9 +127,9 @@ export async function loadBinding(initialPath: string): Promise<Binding> {
) )
).flat(); ).flat();
// Load and check processors // Load and check plugins
const processors: MuseProcessor[] = await Promise.all( const plugins: MusePlugin[] = await Promise.all(
parsedBinding.processors.map(loadProcessor.bind(null, bindingDirname)), parsedBinding.processors.map(loadPlugin.bind(null, bindingDirname)),
); );
return { return {
@ -144,7 +138,7 @@ export async function loadBinding(initialPath: string): Promise<Binding> {
contentKey: parsedBinding.contentKey, contentKey: parsedBinding.contentKey,
entries, entries,
options: parsedBinding.options, options: parsedBinding.options,
processors: processors, plugins,
meta: {}, 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 EMDY from "@endeavorance/emdy";
import TOML from "smol-toml"; import TOML from "smol-toml";
import YAML from "yaml"; import YAML from "yaml";
import type { UnknownRecord } from "#types"; import { loadFileContent } from "./files";
import { MuseFileNotFoundError } from "#errors"; import type { UnknownRecord } from "./types";
export interface MuseEntry<MetaShape = UnknownRecord> { export interface MuseEntry<MetaShape = UnknownRecord> {
_raw: string; _raw: string;
@ -12,6 +11,20 @@ export interface MuseEntry<MetaShape = UnknownRecord> {
meta: MetaShape; 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[] { function parseYAMLEntries(text: string): UnknownRecord[] {
const parsedDocs = YAML.parseAllDocuments(text); const parsedDocs = YAML.parseAllDocuments(text);
@ -32,31 +45,15 @@ function parseYAMLEntries(text: string): UnknownRecord[] {
} }
function parseJSONEntries(text: string): UnknownRecord[] { function parseJSONEntries(text: string): UnknownRecord[] {
const parsed = JSON.parse(text); return formatEntries(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[] { function parseTOMLEntries(text: string): UnknownRecord[] {
const parsed = TOML.parse(text); return formatEntries(TOML.parse(text));
}
if (Array.isArray(parsed)) { function parseMarkdownEntry(text: string, contentKey: string): UnknownRecord[] {
return parsed; return formatEntries(EMDY.parse(text, contentKey));
}
if (typeof parsed === "object") {
return [parsed];
}
throw new Error("TOML resource must be an object or an array of objects");
} }
interface ParseMuseFileOptions { interface ParseMuseFileOptions {
@ -67,49 +64,37 @@ export async function parseMuseFile(
rawFilePath: string, rawFilePath: string,
{ contentKey = "content" }: ParseMuseFileOptions = {}, { contentKey = "content" }: ParseMuseFileOptions = {},
): Promise<MuseEntry[]> { ): Promise<MuseEntry[]> {
const filePath = normalize(rawFilePath); const { content, filePath, fileType } = await loadFileContent(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 partial = { const partial = {
_raw: rawFileContent, _raw: content,
filePath, filePath,
meta: {}, meta: {},
}; };
if (fileType === "md") { if (fileType === "md") {
const parsed = EMDY.parse(rawFileContent, contentKey); return parseMarkdownEntry(content, contentKey).map((data) => ({
return [
{
...partial, ...partial,
data: parsed, data,
}, }));
];
} }
if (fileType === "yaml") { if (fileType === "yaml") {
return parseYAMLEntries(rawFileContent).map((data) => ({ return parseYAMLEntries(content).map((data) => ({
...partial, ...partial,
data, data,
})); }));
} }
if (fileType === "json") { if (fileType === "json") {
return parseJSONEntries(rawFileContent).map((data) => ({ return parseJSONEntries(content).map((data) => ({
...partial, ...partial,
data, data,
})); }));
} }
if (fileType === "toml") { if (fileType === "toml") {
return parseTOMLEntries(rawFileContent).map((data) => ({ return parseTOMLEntries(content).map((data) => ({
...partial, ...partial,
data, 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>; export type UnknownRecord = Record<string, unknown>;

View file

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

View file

@ -1,3 +1,9 @@
export type * from "#types"; // Content exports
export * from "#errors"; export * from "#errors";
// Constants
export const DEFAULT_CONTENT_KEY = "content"; 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": [
"./util.ts" "./util.ts"
], ],
"#types": [
"./types.ts"
],
"#errors": [ "#errors": [
"./errors.ts" "./errors.ts"
] ]