import * as nodePath from "node:path"; import * as fs from "node:fs/promises"; import matter from "gray-matter"; import { validateFrontmatter, LureParseError } from "./schema.js"; import type { ParsedLure } from "./types.js"; import type { StandardSchemaV1 } from "@standard-schema/spec"; export { LureParseError }; export class LureCache { private readonly byLurePath = new Map(); private readonly byFilePath = new Map(); constructor( private readonly luresDir: string, private readonly allowUnverified: boolean, private readonly configSchema: StandardSchemaV1 | undefined, ) {} async load(): Promise { const entries = await fs.readdir(this.luresDir, { recursive: true }); for (const entry of entries) { if (typeof entry === "string" && entry.endsWith(".lure")) { const filePath = nodePath.join(this.luresDir, entry); try { await this.set(filePath); } catch (error) { console.error(`Failed to load lure ${filePath}:`, error); } } } } async set(filePath: string): Promise { const resolved = nodePath.resolve(filePath); const resolvedDir = nodePath.resolve(this.luresDir); if (!resolved.startsWith(resolvedDir + nodePath.sep) && resolved !== resolvedDir) { throw new LureParseError(`Path traversal detected: ${filePath}`, filePath); } const content = await fs.readFile(filePath, "utf-8"); const { data, content: template } = matter(content); const frontmatter = await validateFrontmatter(data, filePath, this.configSchema); if (!this.allowUnverified && frontmatter.verify === undefined) { throw new LureParseError( `Lure has no verify block but allowUnverified is false: ${filePath}`, filePath, ); } const relative = nodePath.relative(resolvedDir, nodePath.resolve(filePath)); const lurePath = "/" + relative.replace(/\.lure$/, "").replace(/\\/g, "/"); const parsed: ParsedLure = { filePath, lurePath, frontmatter, template: template.trim(), }; const oldLurePath = this.byFilePath.get(filePath); if (oldLurePath !== undefined) { this.byLurePath.delete(oldLurePath); } this.byLurePath.set(lurePath, parsed); this.byFilePath.set(filePath, lurePath); } delete(filePath: string): void { const lurePath = this.byFilePath.get(filePath); if (lurePath !== undefined) { this.byLurePath.delete(lurePath); this.byFilePath.delete(filePath); } } get(lurePath: string): ParsedLure | undefined { return this.byLurePath.get(lurePath); } }