import { Glob } from "bun"; import { type LoadedFile, loadFileContent } from "./files"; import { type UnknownRecord, autoParseEntry } from "./records"; import { z } from "zod"; import { MuseError } from "#errors"; export const SourceSchema = z.union([ z.object({ file: z.string(), }), z.object({ url: z.string().url(), }), ]); export type SourceShorthand = z.infer; export function sourceShorthandToSource( shorthand: SourceShorthand, ): MuseSource { if ("file" in shorthand) { return { location: shorthand.file, type: "file", }; } if ("url" in shorthand) { return { location: shorthand.url, type: "url", }; } throw new MuseError("Invalid source shorthand"); } export type SourceType = "file" | "url"; /** A descriptor of a location to load into a Muse binding */ export interface MuseSource { location: string; type: SourceType; } interface SourceOptions { cwd: string; contentKey: string; ignore: string[]; } /** A single loaded entry from a Muse source */ export interface MuseEntry { _raw: string; location: string; data: Record; meta: MetaShape; source: MuseSource; } export async function parseMuseFile( rawFilePath: string, { contentKey = "content" }: SourceOptions, ): Promise { const file = await loadFileContent(rawFilePath); const entries = autoParseEntry(file, contentKey); const partial = { _raw: file.content, source: { location: file.filePath, type: "file" as SourceType, }, location: file.filePath, meta: {}, }; return entries.map((data) => ({ ...partial, data, })); } async function loadFromFileSource( source: MuseSource, options: SourceOptions, ): Promise { const paths = Array.from( new Glob(source.location).scanSync({ cwd: options.cwd, absolute: true, followSymlinks: true, onlyFiles: true, }), ); const filteredPaths = paths.filter((path) => !options.ignore.includes(path)); const entries: MuseEntry[] = []; for (const filePath of filteredPaths) { const fileEntries = await parseMuseFile(filePath, options); entries.push(...fileEntries); } return entries; } function getFileExtensionFromURL(url: string): string | null { const parsedUrl = new URL(url); const pathname = parsedUrl.pathname; const filename = pathname.substring(pathname.lastIndexOf("/") + 1); const extension = filename.substring(filename.lastIndexOf(".") + 1); return extension === filename ? null : extension; } async function loadFromURLSource( source: MuseSource, options: SourceOptions, ): Promise { const { contentKey = "content" } = options; const response = await fetch(source.location); if (!response.ok) { throw new Error(`Failed to fetch URL: ${source.location}`); } const content = await response.text(); const mimeType = response.headers.get("Content-Type") || "unknown"; const parseType = getFileExtensionFromURL(source.location) ?? "unknown"; const loadedFile: LoadedFile = { content, filePath: source.location, fileType: parseType, dirname: "", basename: "", mimeType, }; const entries = autoParseEntry(loadedFile, contentKey); const partial = { _raw: content, source: { location: source.location, type: "url" as SourceType, }, location: source.location, meta: {}, }; return entries.map((data) => ({ ...partial, data, })); } export async function loadFromSource( source: MuseSource, options: SourceOptions, ): Promise { switch (source.type) { case "file": return loadFromFileSource(source, options); case "url": return loadFromURLSource(source, options); default: throw new Error(`Unsupported source type: ${source.type}`); } }