/** * Glob Loader - 패턴 매칭 파일 로더 * * Glob 패턴으로 여러 파일을 로드 (Markdown, JSON 등) * * @example * ```ts * glob({ pattern: 'content/posts/**\/*.md' }) * glob({ pattern: ['content/blog/**\/*.md', 'content/news/**\/*.md'] }) * ``` */ import type { Loader, GlobLoaderOptions, ParsedMarkdown } from "./types"; import type { LoaderContext, DataEntry } from "../types"; import { ParseError } from "../types"; import { generateFileDigest, combineDigests } from "../digest"; import { inferParser } from "./types"; import * as fs from "fs"; import * as path from "path"; // fast-glob 동적 임포트 async function fastGlob(patterns: string[], options: { onlyFiles: boolean; absolute: boolean }): Promise { const fg = await import("fast-glob"); return fg.glob(patterns, options); } /** * 프론트매터 파싱 (---로 구분된 YAML) */ async function parseFrontmatter( content: string, filePath: string ): Promise { const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; const match = content.match(frontmatterRegex); if (!match) { // 프론트매터 없음 return { data: {}, body: content.trim(), }; } const [, rawFrontmatter, body] = match; try { // yaml 동적 로드 // @ts-ignore dynamic optional import const yaml = await import("yaml").catch(() => null); if (!yaml) { // yaml 없으면 빈 데이터 반환 console.warn( `[GlobLoader] yaml package not installed, frontmatter ignored for ${filePath}` ); return { data: {}, body: body.trim(), rawFrontmatter, }; } const data = yaml.parse(rawFrontmatter) || {}; return { data: typeof data === "object" ? data : {}, body: body.trim(), rawFrontmatter, }; } catch (error) { throw new ParseError( `Failed to parse frontmatter: ${error instanceof Error ? error.message : error}`, undefined, filePath ); } } /** * 파일 경로에서 ID 생성 (기본) */ function defaultGenerateId(params: { filePath: string; base: string }): string { const { filePath, base } = params; // base 기준 상대 경로 const relativePath = path.relative(base, filePath); // 확장자 제거, 경로 구분자를 슬래시로 통일 const withoutExt = relativePath.replace(/\.[^.]+$/, ""); const normalized = withoutExt.replace(/\\/g, "/"); // index 파일은 상위 폴더명 사용 if (normalized.endsWith("/index")) { return normalized.slice(0, -6) || "index"; } return normalized; } /** * Glob Loader 팩토리 */ export function glob(options: GlobLoaderOptions): Loader { const { pattern, base, generateId = defaultGenerateId } = options; return { name: "glob", async load(context: LoaderContext): Promise { const { store, config, logger, parseData: _parseData, renderMarkdown: _renderMarkdown, watcher } = context; // 기본 디렉토리 const baseDir = base ? path.isAbsolute(base) ? base : path.join(config.root, base) : config.root; // 패턴 배열로 통일 const patterns = Array.isArray(pattern) ? pattern : [pattern]; // 절대 패턴으로 변환 const absolutePatterns = patterns.map((p) => path.isAbsolute(p) ? p : path.join(config.root, p) ); // 파일 검색 const filePaths = await fastGlob(absolutePatterns, { onlyFiles: true, absolute: true, }); if (filePaths.length === 0) { logger.warn(`No files matched pattern: ${patterns.join(", ")}`); return; } // 파일 감시 추가 (dev 모드) if (watcher) { watcher.add(absolutePatterns); } // 기존 엔트리 ID 수집 (삭제 감지용) const existingIds = new Set(store.keys()); const processedIds = new Set(); // 병렬 처리 await Promise.all( filePaths.map(async (filePath) => { const id = generateId({ filePath, base: baseDir }); processedIds.add(id); try { await processFile(context, filePath, id, baseDir); } catch (error) { logger.error( `Failed to process ${filePath}: ${error instanceof Error ? error.message : error}` ); } }) ); // 삭제된 파일 처리 for (const id of existingIds) { if (!processedIds.has(id)) { store.delete(id); logger.debug(`Removed deleted entry: ${id}`); } } logger.info(`Processed ${processedIds.size} files`); }, }; } /** * 단일 파일 처리 */ async function processFile( context: LoaderContext, filePath: string, id: string, _baseDir: string ): Promise { const { store, parseData, renderMarkdown, logger } = context; // 파일 읽기 const content = fs.readFileSync(filePath, "utf-8"); const fileDigest = generateFileDigest(content); // 기존 엔트리와 비교 const existingEntry = store.get(id); if (existingEntry?.digest === fileDigest) { logger.debug(`Unchanged: ${id}`); return; } // 파서 타입 결정 const parserType = inferParser(filePath); let entry: DataEntry; if (parserType === "markdown") { // Markdown 파일 처리 const parsed = await parseFrontmatter(content, filePath); // 프론트매터 검증 const data = await parseData({ id, data: parsed.data, filePath, }); // 다이제스트 (프론트매터 + 본문) const digest = combineDigests([ fileDigest, context.generateDigest(parsed.data), ]); entry = { id, data: data as Record, filePath, body: parsed.body, digest, }; // Markdown 렌더링 (옵션) if (renderMarkdown) { entry.rendered = await renderMarkdown(parsed.body); } } else if (parserType === "json") { // JSON 파일 const rawData = JSON.parse(content); const data = await parseData({ id, data: rawData, filePath }); entry = { id, data: data as Record, filePath, digest: fileDigest, }; } else { // 지원하지 않는 파일 타입 logger.warn(`Unsupported file type: ${filePath}`); return; } // 저장 store.set(entry); logger.debug(`Processed: ${id}`); }