import path from "node:path"; import { Glob } from "bun"; import z from "zod"; import { getEmptyPlaybill, type UnknownResource, type Playbill, } from "@proscenium/playbill"; import { loadYAMLFileOrFail } from "./files"; import { loadResourceFile } from "./resource"; import { FileNotFoundError } from "./errors"; const HTMLRenderOptionsSchema = z.object({ stylesheet: 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; 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; playbill: Playbill; } export async function resolveBindingPath(bindingPath: string): Promise { // 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 { 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, }; }