Organize into folders
This commit is contained in:
parent
84b8f1c9d6
commit
2e0d4b45ea
12 changed files with 121 additions and 64 deletions
151
src/lib/binding.ts
Normal file
151
src/lib/binding.ts
Normal 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
32
src/lib/errors.ts
Normal 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
77
src/lib/markdown.ts
Normal 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
118
src/lib/resource.ts
Normal 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);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue