muse/src/core/sources.ts
2025-04-04 14:07:59 -04:00

172 lines
3.8 KiB
TypeScript

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<typeof SourceSchema>;
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<MetaShape = UnknownRecord> {
_raw: string;
location: string;
data: Record<string, unknown>;
meta: MetaShape;
source: MuseSource;
}
export async function parseMuseFile(
rawFilePath: string,
{ contentKey = "content" }: SourceOptions,
): Promise<MuseEntry[]> {
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<MuseEntry[]> {
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<MuseEntry[]> {
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<MuseEntry[]> {
switch (source.type) {
case "file":
return loadFromFileSource(source, options);
case "url":
return loadFromURLSource(source, options);
default:
throw new Error(`Unsupported source type: ${source.type}`);
}
}