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 { // 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 { 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[] = []; 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, }; }