From 4a3ca8451a25f07c550b000a3977dae34d71f3e3 Mon Sep 17 00:00:00 2001 From: malloc72p Date: Wed, 1 Jul 2026 09:57:17 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(blog):=20frontmatter=20=ED=8C=8C?= =?UTF-8?q?=EC=84=9C=20=EA=B2=AC=EA=B3=A0=ED=99=94=20=E2=80=94=20=EB=94=B0?= =?UTF-8?q?=EC=98=B4=ED=91=9C=20=EC=A2=85=EB=A5=98=20=ED=99=95=EC=9E=A5=20?= =?UTF-8?q?+=20=ED=8C=8C=EC=8B=B1=20=EC=8B=A4=ED=8C=A8=EB=A5=BC=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=8B=A4=ED=8C=A8=EB=A1=9C=20=EC=8A=B9?= =?UTF-8?q?=EA=B2=A9=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extractStringField/extractArrayField: 작은따옴표 전용 → 작은/큰따옴표/백틱 모두 허용 - parseFrontmatterMetadata/extractMetadata: null 반환(silent skip) → 파일 경로를 포함해 throw 블록 미발견, title/date 누락, fs 읽기 실패 모두 빌드를 실패시켜 포스트가 목록에서 조용히 사라지는 것을 방지 - getAllMdxFiles: metadata null 체크 제거(더 이상 nullable 아님, 예외는 상위로 전파) - mdx-utils.test: 두 실패 케이스를 throw 기대로 수정 + 큰따옴표/백틱/에러 메시지 경로 케이스 추가 - 캐싱(성능)은 이번 범위에서 제외 — 파서 정합성만 우선 처리 Co-Authored-By: Claude Opus 4.8 --- apps/blog/src/libs/api/mdx-utils.test.ts | 32 +++++++++++---- apps/blog/src/libs/api/mdx-utils.ts | 52 +++++++++++++----------- 2 files changed, 54 insertions(+), 30 deletions(-) diff --git a/apps/blog/src/libs/api/mdx-utils.test.ts b/apps/blog/src/libs/api/mdx-utils.test.ts index 1c3abdb..009dca3 100644 --- a/apps/blog/src/libs/api/mdx-utils.test.ts +++ b/apps/blog/src/libs/api/mdx-utils.test.ts @@ -27,26 +27,44 @@ 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'); }); }); diff --git a/apps/blog/src/libs/api/mdx-utils.ts b/apps/blog/src/libs/api/mdx-utils.ts index e2d081d..a09e4ed 100644 --- a/apps/blog/src/libs/api/mdx-utils.ts +++ b/apps/blog/src/libs/api/mdx-utils.ts @@ -33,16 +33,16 @@ export async function getAllMdxFiles(dir: string = POSTS_DIR): Promise item.slice(1, -1)); + return (arrayContent.match(/['"`][^'"`]*['"`]/g) ?? []).map((item) => item.slice(1, -1)); } From 8bd02ed5556360d43ac3af9ee630d6a8029e83cb Mon Sep 17 00:00:00 2001 From: malloc72p Date: Wed, 1 Jul 2026 09:58:22 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs(blog):=20=ED=8C=8C=EC=84=9C=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=EC=8B=9D=EC=9D=98=20=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=ED=94=84=20=EB=AF=B8=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=ED=95=9C=EA=B3=84=EB=A5=BC=20=EC=A3=BC=EC=84=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=AA=85=EC=8B=9C=20(#99=20follow-up)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 중 확인: 실제 frontmatter 값 중 내부에 혼합 따옴표를 쓰는 사례는 없음(검증 완료). 값 내부 이스케이프는 정규식 파서의 알려진 한계로 남기고, 왜 다루지 않는지 주석으로 근거를 남김. Co-Authored-By: Claude Opus 4.8 --- apps/blog/src/libs/api/mdx-utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/blog/src/libs/api/mdx-utils.ts b/apps/blog/src/libs/api/mdx-utils.ts index a09e4ed..0055e27 100644 --- a/apps/blog/src/libs/api/mdx-utils.ts +++ b/apps/blog/src/libs/api/mdx-utils.ts @@ -116,6 +116,8 @@ export function parseFrontmatterMetadata( } // 작은따옴표/큰따옴표/백틱 중 어떤 것으로 감싸도 값을 추출한다(#99: 따옴표 종류 보강). +// 값 내부에 이스케이프된 같은 종류 따옴표(예: "it's")가 있으면 그 지점에서 잘린다. +// 정규식 파서의 알려진 한계이며, 현재 실제 frontmatter 값에는 해당 사례가 없어 의도적으로 다루지 않는다. function extractStringField(body: string, field: string): string | null { const regex = new RegExp(`${field}:\\s*(['"\`])([^'"\`]*)\\1`); return body.match(regex)?.[2] ?? null; From e2e3ed3672e3d4e392ecc1f0a362a6493a2148f4 Mon Sep 17 00:00:00 2001 From: malloc72p Date: Wed, 1 Jul 2026 10:23:12 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test(blog):=20getAllMdxFiles=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=E2=80=94=20=EC=8B=A4=EC=A0=9C=20fs=20=EC=8A=A4=EC=BA=94=20+=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=A0=84=ED=8C=8C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?(#99=20follow-up)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 api 테스트(find-posts/find-tags/find-series)는 전부 getAllMdxFiles를 mock하므로 실제 fs 스캔이나 parseFrontmatterMetadata의 throw 전파는 어디서도 검증되지 않고 있었다 (과거 이를 real fs로 검증하던 constants.test.ts는 #89에서 삭제됨). - '실제 posts 디렉토리 스캔 시 던지지 않고 모든 글이 title/date를 갖는다' — 회귀 가드. 향후 어떤 포스트의 frontmatter가 깨지면 pnpm build를 기다리지 않고 pnpm test에서 바로 드러남 - '깨진 frontmatter가 있으면 getAllMdxFiles 호출 자체가 던진다' — 임시 디렉토리 fixture로 parseFrontmatterMetadata의 throw가 getAllMdxFiles까지 정상 전파됨을 검증 Co-Authored-By: Claude Opus 4.8 --- apps/blog/src/libs/api/mdx-utils.test.ts | 40 +++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/blog/src/libs/api/mdx-utils.test.ts b/apps/blog/src/libs/api/mdx-utils.test.ts index 009dca3..1205cc7 100644 --- a/apps/blog/src/libs/api/mdx-utils.test.ts +++ b/apps/blog/src/libs/api/mdx-utils.test.ts @@ -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) => @@ -68,3 +71,38 @@ describe('parseFrontmatterMetadata', () => { 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 }); + } + }); +});