Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 64 additions & 8 deletions apps/blog/src/libs/api/mdx-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { parseFrontmatterMetadata } from '@libs/api/mdx-utils';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { getAllMdxFiles, parseFrontmatterMetadata } from '@libs/api/mdx-utils';

// 실제 page.mdx 상단 형태를 흉내 내어 frontmatter 호출 블록을 감싼다.
const wrap = (body: string) =>
Expand Down Expand Up @@ -27,26 +30,79 @@ describe('parseFrontmatterMetadata', () => {
});
});

it('frontmatter 호출이 없으면 null', () => {
expect(parseFrontmatterMetadata('# 그냥 글\n본문만 있음')).toBeNull();
it('frontmatter 호출이 없으면 던진다(#99: silent skip 금지)', () => {
expect(() => parseFrontmatterMetadata('# 그냥 글\n본문만 있음')).toThrow(
/frontmatter\(\{\.\.\.\}\) 블록을 찾을 수 없습니다/,
);
});

it('title 또는 date가 없으면 던진다(#99: silent skip 금지)', () => {
expect(() => parseFrontmatterMetadata(wrap(`title: 'T'`))).toThrow(
/필수 필드\(title\/date\)가 없습니다/,
);
});

it('에러 메시지에 파일 경로를 포함한다', () => {
expect(() => parseFrontmatterMetadata('본문만', '/fake/page.mdx')).toThrow(/\/fake\/page\.mdx/);
});

it('큰따옴표로 감싼 값도 추출한다(#99: 따옴표 종류 보강)', () => {
const fm = parseFrontmatterMetadata(wrap(`title: "큰따옴표 제목", date: "2026-01-01 00:00"`));
expect(fm.title).toBe('큰따옴표 제목');
});

it('title 또는 date가 없으면 null', () => {
expect(parseFrontmatterMetadata(wrap(`title: 'T'`))).toBeNull();
it('백틱으로 감싼 값도 추출한다(#99: 따옴표 종류 보강)', () => {
const fm = parseFrontmatterMetadata(wrap('title: `백틱 제목`, date: `2026-01-01 00:00`'));
expect(fm.title).toBe('백틱 제목');
});

it('isSeriesLanding: true를 인식하고 postId 없으면 id=seriesId', () => {
const fm = parseFrontmatterMetadata(
wrap(`title: '프론트엔드', seriesId: 'frontend', date: '2025-01-01 00:00', isSeriesLanding: true`),
);
expect(fm?.isSeriesLanding).toBe(true);
expect(fm?.id).toBe('frontend');
expect(fm.isSeriesLanding).toBe(true);
expect(fm.id).toBe('frontend');
});

it('중첩 객체가 있어도 괄호 깊이로 블록 끝을 찾는다', () => {
const fm = parseFrontmatterMetadata(
wrap(`title: 'T', date: '2026-01-01 00:00', nested: { a: { b: 1 } }`),
);
expect(fm?.title).toBe('T');
expect(fm.title).toBe('T');
});
});

// getAllMdxFiles는 다른 api 테스트(find-posts 등)에서 전부 mock되므로, 실제 fs 스캔과
// parseFrontmatterMetadata의 throw가 끝까지 전파되는지는 여기서만 검증한다(#99).
describe('getAllMdxFiles', () => {
it('실제 posts 디렉토리를 mock 없이 스캔해도 던지지 않고, 모든 글이 title/date를 갖는다(회귀 가드)', async () => {
// 실제 page.mdx들이 파싱 가능한 상태를 유지하는지 검증한다.
// 이 테스트가 실패하면 어떤 포스트의 frontmatter가 깨졌다는 뜻이며,
// #99 이전에는 이런 경우 조용히 목록에서 사라졌지만 지금은 여기서 바로 드러난다.
const files = await getAllMdxFiles();

expect(files.length).toBeGreaterThan(0);
files.forEach((file) => {
expect(file.frontMatter.title).toBeTruthy();
expect(file.frontMatter.date).toBeTruthy();
});
});

it('frontmatter가 깨진 파일이 있으면 getAllMdxFiles 호출 자체가 던진다(#99: 전파 검증)', async () => {
// 임시 디렉토리에 page.mdx 2개(정상 1 + 깨진 1)를 만들어 실제 fs 스캔 경로로 검증한다.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mdx-utils-test-'));
try {
const goodDir = path.join(tmpDir, 'good-post');
const badDir = path.join(tmpDir, 'bad-post');
fs.mkdirSync(goodDir);
fs.mkdirSync(badDir);
fs.writeFileSync(path.join(goodDir, 'page.mdx'), wrap(`title: 'T', date: '2026-01-01 00:00'`));
// date 필드가 없는 깨진 frontmatter
fs.writeFileSync(path.join(badDir, 'page.mdx'), wrap(`title: 'T'`));

await expect(getAllMdxFiles(tmpDir)).rejects.toThrow(/필수 필드\(title\/date\)가 없습니다/);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
54 changes: 31 additions & 23 deletions apps/blog/src/libs/api/mdx-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,16 @@ export async function getAllMdxFiles(dir: string = POSTS_DIR): Promise<MdxFileIn
if (entry.isDirectory()) {
scanDir(fullPath);
} else if (entry.name === 'page.mdx' || entry.name === 'page.tsx') {
// 파싱 실패는 extractMetadata가 던지며, 여기서 잡지 않고 그대로 전파해
// 빌드를 실패시킨다(포스트가 조용히 목록에서 사라지는 것을 방지, #99).
const relativePath = path.relative(POSTS_DIR, currentDir);
const metadata = extractMetadata(fullPath);
if (metadata) {
const relativePath = path.relative(POSTS_DIR, currentDir);
files.push({
slug: relativePath,
route: `/posts/${relativePath}`,
filePath: fullPath,
frontMatter: metadata,
});
}
files.push({
slug: relativePath,
route: `/posts/${relativePath}`,
filePath: fullPath,
frontMatter: metadata,
});
}
}
}
Expand All @@ -55,25 +55,28 @@ export async function getAllMdxFiles(dir: string = POSTS_DIR): Promise<MdxFileIn
/**
* 파일 텍스트에서 frontmatter({...}) 블록을 파싱하여 메타데이터를 추출
* 동적 import 대신 정적 파일 읽기를 사용하여 Turbopack 호환성을 확보
* fs 읽기 실패·파싱 실패는 여기서 삼키지 않고 그대로 던진다(#99).
*/
function extractMetadata(filePath: string): MdxFileInfo['frontMatter'] | null {
try {
const content = fs.readFileSync(filePath, 'utf-8');
return parseFrontmatterMetadata(content);
} catch (e) {
console.error(`Failed to parse metadata from ${filePath}`, e);
return null;
}
function extractMetadata(filePath: string): MdxFileInfo['frontMatter'] {
const content = fs.readFileSync(filePath, 'utf-8');
return parseFrontmatterMetadata(content, filePath);
}

/**
* 파일 본문 문자열에서 frontmatter({ ... }) 블록을 파싱해 메타데이터를 추출한다.
* fs 접근과 분리된 순수 함수라 단위 테스트가 용이하다.
* 블록을 못 찾거나 필수 필드(title/date)가 없으면 던진다 — 포스트가 빌드 성공인 채로
* 목록에서 조용히 누락되는 것을 막기 위해서다(#99, silent skip 금지).
*/
export function parseFrontmatterMetadata(content: string): MdxFileInfo['frontMatter'] | null {
export function parseFrontmatterMetadata(
content: string,
filePath = '(알 수 없는 파일)',
): MdxFileInfo['frontMatter'] {
const startMarker = 'frontmatter({';
const startIndex = content.indexOf(startMarker);
if (startIndex === -1) return null;
if (startIndex === -1) {
throw new Error(`frontmatter({...}) 블록을 찾을 수 없습니다: ${filePath}`);
}

const blockStart = startIndex + startMarker.length;
let depth = 1;
Expand All @@ -91,7 +94,9 @@ export function parseFrontmatterMetadata(content: string): MdxFileInfo['frontMat
const title = extractStringField(body, 'title');
const date = extractStringField(body, 'date');

if (!title || !date) return null;
if (!title || !date) {
throw new Error(`frontmatter에 필수 필드(title/date)가 없습니다: ${filePath}`);
}

const description = extractStringField(body, 'description') ?? undefined;
const seriesId = extractStringField(body, 'seriesId') ?? undefined;
Expand All @@ -110,14 +115,17 @@ export function parseFrontmatterMetadata(content: string): MdxFileInfo['frontMat
};
}

// 작은따옴표/큰따옴표/백틱 중 어떤 것으로 감싸도 값을 추출한다(#99: 따옴표 종류 보강).
// 값 내부에 이스케이프된 같은 종류 따옴표(예: "it's")가 있으면 그 지점에서 잘린다.
// 정규식 파서의 알려진 한계이며, 현재 실제 frontmatter 값에는 해당 사례가 없어 의도적으로 다루지 않는다.
function extractStringField(body: string, field: string): string | null {
const regex = new RegExp(`${field}:\\s*'([^']*)'`);
return body.match(regex)?.[1] ?? null;
const regex = new RegExp(`${field}:\\s*(['"\`])([^'"\`]*)\\1`);
return body.match(regex)?.[2] ?? null;
}

function extractArrayField(body: string, field: string): string[] {
const match = body.match(new RegExp(`${field}:\\s*\\[([^\\]]*)\\]`));
const arrayContent = match?.[1];
if (!arrayContent) return [];
return (arrayContent.match(/'([^']*)'/g) ?? []).map((item) => item.slice(1, -1));
return (arrayContent.match(/['"`][^'"`]*['"`]/g) ?? []).map((item) => item.slice(1, -1));
}
Loading