Organize into folders

This commit is contained in:
Endeavorance 2025-03-10 20:58:11 -04:00
parent 84b8f1c9d6
commit 2e0d4b45ea
12 changed files with 121 additions and 64 deletions

151
src/lib/binding.ts Normal file
View file

@ -0,0 +1,151 @@
import path from "node:path";
import {
type Playbill,
type UnknownResource,
getEmptyPlaybill,
} from "@proscenium/playbill";
import { Glob } from "bun";
import z from "zod";
import { FileNotFoundError } from "#lib/errors";
import { loadYAMLFileOrFail } from "#util/files";
import { loadResourceFile } from "./resource";
const HTMLRenderOptionsSchema = z.object({
styles: z.array(z.string()).optional(),
});
const BindingFileSchema = z.object({
$binding: z.literal("playbill"),
extends: z.string().optional(),
id: z.string(),
name: z.string().default("Unnamed playbill"),
author: z.string().optional(),
description: z.string().optional(),
version: z.string(),
files: z.array(z.string()).default(["**/*.yaml", "**/*.md"]),
html: HTMLRenderOptionsSchema.optional(),
});
type BindingFileContent = z.infer<typeof BindingFileSchema>;
export interface PlaybillBinding {
_raw: BindingFileContent;
// File information
bindingFilePath: string;
bindingFileDirname: string;
includedFiles: string[];
// Binding properties
id: string;
name: string;
author: string;
version: string;
description: string;
html?: z.infer<typeof HTMLRenderOptionsSchema>;
playbill: Playbill;
}
export async function resolveBindingPath(bindingPath: string): Promise<string> {
// If the path does not specify a filename, use binding.yamk
const inputLocation = Bun.file(bindingPath);
// If it is a directory, try again seeking a binding.yaml inside
const stat = await inputLocation.stat();
if (stat.isDirectory()) {
return resolveBindingPath(path.resolve(bindingPath, "binding.yaml"));
}
// If it doesnt exist, bail
if (!(await inputLocation.exists())) {
throw new FileNotFoundError(bindingPath);
}
// Resolve the path
return path.resolve(bindingPath);
}
export async function loadFromBinding(
bindingPath: string,
): Promise<PlaybillBinding> {
const resolvedBindingPath = await resolveBindingPath(bindingPath);
const yamlContent = await loadYAMLFileOrFail(resolvedBindingPath);
const binding = BindingFileSchema.parse(yamlContent);
const fileGlobs = binding.files;
const bindingFileDirname = bindingPath.split("/").slice(0, -1).join("/");
let basePlaybill: Playbill | null = null;
// If this is extending another binding, load that first
if (binding.extends) {
const pathFromHere = path.resolve(bindingFileDirname, binding.extends);
const extendBindingPath = await resolveBindingPath(pathFromHere);
const loadedBinding = await loadFromBinding(extendBindingPath);
basePlaybill = loadedBinding.playbill;
}
const allFilePaths: string[] = [];
for (const thisGlob of fileGlobs) {
const glob = new Glob(thisGlob);
const results = glob.scanSync({
cwd: bindingFileDirname,
absolute: true,
followSymlinks: true,
onlyFiles: true,
});
allFilePaths.push(...results);
}
const bindingFileAbsolutePath = path.resolve(bindingPath);
const filePathsWithoutBindingFile = allFilePaths.filter((filePath) => {
return filePath !== bindingFileAbsolutePath;
});
// -- LOAD ASSOCIATED RESOURCE FILES -- //
const loadedResources: UnknownResource[] = [];
for (const filepath of filePathsWithoutBindingFile) {
const loaded = await loadResourceFile(filepath);
loadedResources.push(...loaded);
}
// -- COMPILE PLAYBILL FROM RESOURCES --//
const playbill = basePlaybill ?? getEmptyPlaybill();
playbill.id = binding.id;
playbill.name = binding.name;
playbill.author = binding.author ?? "Anonymous";
playbill.version = binding.version;
playbill.description = binding.description ?? "";
for (const resource of loadedResources) {
const resourceType = resource.$define;
const resourceMap = playbill[resourceType] as Record<
string,
typeof resource
>;
resourceMap[resource.id] = resource;
}
return {
_raw: binding,
bindingFilePath: bindingPath,
bindingFileDirname,
includedFiles: filePathsWithoutBindingFile,
id: binding.id,
name: binding.name,
author: binding.author ?? "Anonymous",
description: binding.description ?? "",
version: binding.version,
html: binding.html,
playbill,
};
}

32
src/lib/errors.ts Normal file
View file

@ -0,0 +1,32 @@
export class MuseError extends Error {
fmt(): string {
return this.message;
}
}
export class FileError extends MuseError {
filePath: string;
details?: string;
constructor(message: string, filePath: string, details?: string) {
super(message);
this.filePath = filePath;
this.details = details;
}
fmt(): string {
return `-- ${this.message} --\nFile Path: ${this.filePath}\nDetails: ${this.details}`;
}
}
export class MalformedResourceFileError extends FileError {}
export class FileNotFoundError extends FileError {
constructor(filePath: string) {
super("File not found", filePath);
this.message = "File not found";
this.filePath = filePath;
}
}
export class CLIError extends MuseError {}

77
src/lib/markdown.ts Normal file
View file

@ -0,0 +1,77 @@
import rehypeStringify from "rehype-stringify";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
import YAML from "yaml";
const FRONTMATTER_REGEX = /^---[\s\S]*?---/gm;
/**
* Attempt to parse YAML frontmatter from a mixed yaml/md doc
* @param content The raw markdown content
* @returns Any successfully parsed frontmatter
*/
export function extractFrontmatter(content: string) {
// If it does not start with `---`, it is invalid for frontmatter
if (content.trim().indexOf("---") !== 0) {
return {};
}
if (FRONTMATTER_REGEX.test(content)) {
const frontmatterString = content.match(FRONTMATTER_REGEX)?.[0] ?? "";
const cleanFrontmatter = frontmatterString.replaceAll("---", "").trim();
return YAML.parse(cleanFrontmatter);
}
return {};
}
/**
* Given a string of a markdown document, extract the markdown content
* @param content The raw markdown content
* @returns The markdown content without frontmatter
*/
export function extractMarkdown(content: string): string {
if (content.trim().indexOf("---") !== 0) {
return content;
}
return content.replace(FRONTMATTER_REGEX, "").trim();
}
/**
* Combine yaml frontmatter and markdown into a single string
* @param markdown The markdown to combine with frontmatter
* @param frontmatter The frontmatter shape to combine with markdown
* @returns A combined document with yaml frontmatter and markdown below
*/
export function combineMarkdownAndFrontmatter(
markdown: string,
frontmatter: unknown,
) {
const frontmatterToWrite: unknown = structuredClone(frontmatter);
return `---
${YAML.stringify(frontmatterToWrite)}
---
${markdown.trim()}
`;
}
/**
* Given a string of markdown, convert it to HTML
*
* @param markdown The markdown to convert
* @returns A promise resolving to the HTML representation of the markdown
*/
export async function markdownToHtml(markdown: string): Promise<string> {
const rendered = await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeStringify)
.process(markdown);
return String(rendered);
}

118
src/lib/resource.ts Normal file
View file

@ -0,0 +1,118 @@
import {
ResourceMap,
type UnknownResource,
UnknownResourceSchema,
} from "@proscenium/playbill";
import YAML, { YAMLParseError } from "yaml";
import { ZodError } from "zod";
import { MalformedResourceFileError } from "#lib/errors";
import { loadFileOrFail } from "#util/files";
import { extractFrontmatter, extractMarkdown } from "./markdown";
type FileFormat = "yaml" | "markdown";
function determineFileFormat(filePath: string): FileFormat {
return filePath.split(".").pop() === "md" ? "markdown" : "yaml";
}
function loadYamlResourceFile(
filePath: string,
text: string,
): UnknownResource[] {
const parsedDocs = YAML.parseAllDocuments(text);
if (parsedDocs.some((doc) => doc.toJS() === null)) {
throw new MalformedResourceFileError("Encountered NULL resource", filePath);
}
const errors = parsedDocs.flatMap((doc) => doc.errors);
if (errors.length > 0) {
throw new MalformedResourceFileError(
"Error parsing YAML resource",
filePath,
errors.map((e) => e.message).join(", "),
);
}
const collection: UnknownResource[] = [];
for (const doc of parsedDocs) {
const raw = doc.toJS();
const parsed = UnknownResourceSchema.safeParse(raw);
if (!parsed.success) {
throw new MalformedResourceFileError(
"Failed to parse resource",
filePath,
`Schema validation failed: ${parsed.error.message}`,
);
}
const parsedResource = parsed.data;
const type = parsedResource.$define;
const schemaToUse = ResourceMap[type];
const validated = schemaToUse.parse(parsedResource);
collection.push(validated);
}
return collection;
}
function loadMarkdownResourceFile(
filePath: string,
text: string,
): UnknownResource[] {
try {
const frontmatter = extractFrontmatter(text);
const markdown = extractMarkdown(text);
const together = {
...frontmatter,
description: markdown,
};
const parsed = UnknownResourceSchema.parse(together);
return [parsed];
} catch (e) {
if (e instanceof YAMLParseError) {
throw new MalformedResourceFileError(
"Error parsing Markdown frontmatter",
filePath,
e.message,
);
}
if (e instanceof ZodError) {
throw new MalformedResourceFileError(
"Error parsing resource file",
filePath,
e.message,
);
}
throw e;
}
}
/**
* Load and process all documents in a yaml file at the given path
* @param filePath - The path to the yaml file
* @returns An array of validated resources
*
* @throws ResourceFileError
* @throws NullResourceError
*/
export async function loadResourceFile(
filePath: string,
): Promise<UnknownResource[]> {
const text = await loadFileOrFail(filePath);
const format = determineFileFormat(filePath);
switch (format) {
case "yaml":
return loadYamlResourceFile(filePath, text);
case "markdown":
return loadMarkdownResourceFile(filePath, text);
}
}