/** * LoaderContext Factory - 로더에 전달되는 컨텍스트 생성 * * 각 컬렉션의 load() 함수에 필요한 모든 유틸리티 제공 */ import type { LoaderContext, DataStore, MetaStore, ContentLogger, ManduContentConfig, ContentWatcher, ParseDataOptions, RenderedContent, } from "./types"; import { ValidationError } from "./types"; import { generateDigest } from "./digest"; import type { ZodSchema } from "zod"; /** * LoaderContext 생성 옵션 */ export interface CreateLoaderContextOptions { /** 컬렉션 이름 */ collection: string; /** 데이터 스토어 */ store: DataStore; /** 메타 스토어 */ meta: MetaStore; /** Mandu 설정 */ config: ManduContentConfig; /** Zod 스키마 (검증용) */ schema?: ZodSchema; /** Markdown 렌더러 */ markdownRenderer?: (content: string) => Promise; /** 파일 감시자 */ watcher?: ContentWatcher; /** 로거 (기본: console) */ logger?: ContentLogger; } /** * 기본 콘텐츠 로거 */ function createDefaultLogger(collection: string): ContentLogger { const prefix = `[Content:${collection}]`; return { info: (msg) => console.log(`${prefix} ${msg}`), warn: (msg) => console.warn(`${prefix} ${msg}`), error: (msg) => console.error(`${prefix} ${msg}`), debug: (msg) => { if (process.env.DEBUG || process.env.NODE_ENV === "development") { console.debug(`${prefix} ${msg}`); } }, }; } /** * LoaderContext 생성 */ export function createLoaderContext(options: CreateLoaderContextOptions): LoaderContext { const { collection, store, meta, config, schema, markdownRenderer, watcher, logger = createDefaultLogger(collection), } = options; /** * 데이터 파싱 및 검증 */ async function parseData(parseOptions: ParseDataOptions): Promise { const { id, data, filePath } = parseOptions; if (!schema) { // 스키마 없으면 그대로 반환 return data as T; } const result = await schema.safeParseAsync(data); if (!result.success) { const errorMessage = result.error.issues .map((issue) => `${issue.path.join(".")}: ${issue.message}`) .join("; "); throw new ValidationError( `Validation failed for entry "${id}"${filePath ? ` (${filePath})` : ""}: ${errorMessage}`, result.error, collection, id ); } return result.data as T; } const context: LoaderContext = { collection, store, meta, logger, config, parseData, generateDigest: (data: unknown) => generateDigest(data), watcher, }; // Markdown 렌더러가 있으면 추가 if (markdownRenderer) { context.renderMarkdown = markdownRenderer; } return context; } /** * 간단한 Markdown 렌더러 (기본 제공) * * 실제 프로젝트에서는 remark/rehype 파이프라인으로 교체 권장 */ export function createSimpleMarkdownRenderer(): (content: string) => Promise { return async (content: string): Promise => { // 매우 기본적인 Markdown → HTML 변환 // 실제 사용 시 marked, remark 등으로 교체 const html = content // 헤딩 .replace(/^### (.*$)/gim, "

$1

") .replace(/^## (.*$)/gim, "

$1

") .replace(/^# (.*$)/gim, "

$1

") // 볼드/이탤릭 .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/\*(.+?)\*/g, "$1") // 링크 .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') // 코드 블록 .replace(/```([^`]+)```/g, "
$1
") .replace(/`([^`]+)`/g, "$1") // 줄바꿈 .replace(/\n\n/g, "

") .replace(/\n/g, "
"); // 헤딩 추출 const headings: Array<{ depth: 1 | 2 | 3 | 4 | 5 | 6; slug: string; text: string }> = []; const headingRegex = /^(#{1,6})\s+(.+)$/gm; let match; while ((match = headingRegex.exec(content)) !== null) { const depth = match[1].length as 1 | 2 | 3 | 4 | 5 | 6; const text = match[2].trim(); const slug = text .toLowerCase() .replace(/[^a-z0-9가-힣]+/g, "-") .replace(/^-|-$/g, ""); headings.push({ depth, slug, text }); } return { html: `

${html}

`, headings, }; }; }