muse/src/lib/binding.ts

177 lines
5.1 KiB
TypeScript

import path from "node:path";
import { type AnyPlaybillComponent, Playbill } from "@proscenium/playbill";
import { Glob } from "bun";
import z from "zod";
import { loadYAMLFileOrFail } from "#util";
import { ComponentFile } from "./component-file";
import { FileNotFoundError } from "./errors";
// binding.yaml file schema
const BindingSchema = z.object({
include: z.array(z.string()).default([]),
name: z.string().default("Unnamed playbill"),
author: z.string().default("Someone"),
description: z.string().default("No description provided"),
version: z.string().default("0.0.0"),
files: z.array(z.string()).default(["**/*.yaml", "**/*.md"]),
omitDefaultStyles: z.boolean().default(false),
styles: z
.union([z.string(), z.array(z.string())])
.transform((val) => (Array.isArray(val) ? val : [val]))
.default([]),
terms: z.record(z.string(), z.string()).default({}),
});
export interface BoundPlaybill {
// File information
bindingFilePath: string;
bindingFileDirname: string;
files: string[];
componentFiles: ComponentFile[];
// Binding properties
name: string;
author: string;
version: string;
description: string;
omitDefaultStyles: boolean;
styles: string;
playbill: Playbill;
}
/**
* Given a path, find the binding.yaml file
* If the path is to a directory, check for a binding.yaml inside
* If the path is to a file, check if it exists and is a binding.yaml
* Otherwise throw an error
*
* @param bindingPath - The path to the binding file
* @returns The absolute resolved path to the binding file
*/
export async function resolveBindingPath(bindingPath: string): Promise<string> {
// If the path does not specify a filename, use binding.yaml
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<BoundPlaybill> {
const resolvedBindingPath = await resolveBindingPath(bindingPath);
const yamlContent = await loadYAMLFileOrFail(resolvedBindingPath);
const binding = BindingSchema.parse(yamlContent);
const fileGlobs = binding.files;
const bindingFileDirname = path.dirname(resolvedBindingPath);
const playbill = new Playbill(
binding.name,
binding.author,
binding.description,
binding.version,
);
// If this is extending another binding, load that first
for (const includePath of binding.include) {
const pathFromHere = path.resolve(bindingFileDirname, includePath);
const extendBindingPath = await resolveBindingPath(pathFromHere);
const loadedBinding = await loadFromBinding(extendBindingPath);
playbill.extend(loadedBinding.playbill);
}
// Load any specified stylesheets
const loadedStyles: string[] = [];
for (const style of binding.styles) {
const cssFilePath = path.resolve(bindingFileDirname, style);
const cssFile = Bun.file(cssFilePath);
const cssFileExists = await cssFile.exists();
if (cssFileExists) {
loadedStyles.push(await cssFile.text());
} else {
throw new FileNotFoundError(cssFilePath);
}
}
const styles = loadedStyles.join("\n");
// Scan for all files matching the globs
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);
}
// Exclude the binding.yaml file
const filePathsWithoutBindingFile = allFilePaths.filter((filePath) => {
return filePath !== resolvedBindingPath;
});
// Load discovered component files
const componentFiles: ComponentFile[] = [];
const fileLoadPromises: Promise<void>[] = [];
for (const filepath of filePathsWithoutBindingFile) {
const componentFile = new ComponentFile(filepath);
componentFiles.push(componentFile);
fileLoadPromises.push(componentFile.load());
}
await Promise.all(fileLoadPromises);
// Aggregate all loaded components
const loadedComponents: AnyPlaybillComponent[] = [];
for (const file of componentFiles) {
loadedComponents.push(...file.components);
}
// Add all components to the playbill
// (This method ensures proper addition order maintain correctness)
playbill.addManyComponents(loadedComponents);
// Add all definitions
for (const [term, definition] of Object.entries(binding.terms)) {
playbill.addDefinition(term, definition);
}
return {
bindingFilePath: bindingPath,
bindingFileDirname,
files: filePathsWithoutBindingFile,
componentFiles,
name: binding.name,
author: binding.author ?? "Anonymous",
description: binding.description ?? "",
version: binding.version,
omitDefaultStyles: binding.omitDefaultStyles,
styles,
playbill,
};
}