From 9611d05084a7b5ae2d57c8e8d809ee6d15c6278a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=83=81=EB=B9=88?= Date: Tue, 11 Aug 2026 17:23:35 +0900 Subject: [PATCH 1/2] content: add pinned project introduction post Co-Authored-By: Claude Sonnet 5 --- .../posts/true-log-project-introduction.md | 36 +++++++++++++++++++ tests/lists-taxonomy.test.tsx | 8 ++--- 2 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 content/posts/true-log-project-introduction.md diff --git a/content/posts/true-log-project-introduction.md b/content/posts/true-log-project-introduction.md new file mode 100644 index 0000000..4bfd60e --- /dev/null +++ b/content/posts/true-log-project-introduction.md @@ -0,0 +1,36 @@ +--- +title: "True Log를 시작하며" +description: "Markdown을 정적 페이지로 굽는 한국어 기술 블로그, True Log가 어떻게 만들어졌는지." +date: "2026-08-11" +tags: ["true-log", "nextjs", "markdown", "architecture"] +category: "engineering" +draft: false +pinned: true +--- + +True Log는 Markdown을 정적 페이지로 만드는 한국어 기술 블로그다. Next.js App Router 위에서 돌아가지만, **글을 다루는 부분은 프레임워크를 모른다.** MDX는 없다. Markdown 파이프라인은 문자열을 받아 문자열을 돌려주는 순수 모듈로 분리돼 있고, `unified` 생태계만 쓴다. `next`, `react`, `node:*`, `@/*` 어떤 것도 그 안에서 import할 수 없다. 이건 컨벤션이 아니라 [테스트로 강제된다](tests/markdown-boundary.test.ts) — 허용 목록 방식이라, 아무도 예상 못한 import도 그대로 실패한다. 타입 체크만으로는 이걸 잡지 못한다. 모듈 해석이 상위 `node_modules`까지 걸어 올라가기 때문이다. + +글은 `content/posts/.md`에 놓인다. 파일명이 곧 URL이다. Frontmatter에 제목, 날짜, 태그, 카테고리를 쓰고, 필요하면 `draft`와 `pinned`를 더한다. `draft: true`인 글과 미래 날짜로 예약된 글은 dev 서버에서는 보이지만 production build에는 들어가지 않는다. 이 판단은 `getAllPosts()` 하나에 모여 있다 — 어떤 글이 공개되는지 결정하는 곳이 파일 하나뿐이라는 뜻이다. + +사이트 전체가 `output: "export"`로 나온다. 서버가 없다는 뜻이고, 그 제약이 여러 결정을 대신 내려줬다. CSP nonce는 애초에 쓸 수 없고, RSS feed는 `force-static` 라우트로 만들고, 검색 인덱스는 빌드된 HTML을 읽어야 하니 `postbuild` 단계에서 생성한다. + +다이어그램은 필요할 때만, 클라이언트에서 그린다. Mermaid를 서버에서 렌더링하려면 headless 브라우저가 있어야 한다. 대신 다이어그램이 있는 글에서만 모듈을 불러오고, 그 다이어그램이 `IntersectionObserver`로 화면에 들어올 때만 그린다. + +글 본문 HTML은 `rehype-sanitize`를 반드시 거친다. `dangerouslySetInnerHTML`이 받는 값은 `SanitizedHtml`이라는 branded type으로 좁혀져 있어서, sanitize를 거치지 않은 문자열은 타입 체크에서부터 걸린다. + +디렉터리는 이렇게 나뉜다. + +``` +content/posts/ 글 (Markdown) +src/ + app/ 라우트. (site) 그룹이 공통 레이아웃을 감싼다 + lib/markdown/ Markdown 파이프라인 — 호스트를 모른다 + lib/ 앱 계층: 파일 IO, 캐싱, RSS, SEO + features/ post-toc · post-diagram · search · theme + components/ UI. ui/는 shadcn, typography.tsx가 타입 primitive를 갖는다 + config/ site · navigation · integrations +``` + +문서는 세 개로 나눠 뒀다. [DESIGN.md](DESIGN.md)는 색상·타이포그래피·간격·컴포넌트 규칙을, [CONTENT.md](CONTENT.md)는 frontmatter 스키마와 작성 규칙을, [DEPLOY.md](DEPLOY.md)는 컨테이너 배포와 캐시 정책, 보안 헤더를 담는다. lint, typecheck, test, build 네 개의 게이트가 모든 PR에서 돈다. + +이 블로그에 쓰는 글은 대부분 구현하면서 실제로 마주친 질문과 그 답을 담는다. 왜 이 캐시 전략을 골랐는지, 왜 이 경계를 이렇게 그었는지, 나중에 조건이 바뀌면 무엇을 다시 볼지. True Log 자체가 그 기록의 첫 번째 대상이다. diff --git a/tests/lists-taxonomy.test.tsx b/tests/lists-taxonomy.test.tsx index 202306a..2cd91a4 100644 --- a/tests/lists-taxonomy.test.tsx +++ b/tests/lists-taxonomy.test.tsx @@ -77,13 +77,13 @@ describe("blog lists and taxonomy", () => { expect(categoriesMarkup).toContain("카테고리") expect(categoriesMarkup).toContain("engineering") - expect(categoriesMarkup).toContain("1개") + expect(categoriesMarkup).toContain("2개") expect(tagsMarkup).toContain("태그") expect(tagsMarkup).toContain("markdown") - expect(tagsMarkup).toContain("1개") + expect(tagsMarkup).toContain("2개") expect(categoryMarkup).toContain("engineering") - expect(categoryMarkup).toContain("1개 글") + expect(categoryMarkup).toContain("2개 글") expect(tagMarkup).toContain("markdown") - expect(tagMarkup).toContain("1개 글") + expect(tagMarkup).toContain("2개 글") }) }) From af30e32148d0e50ca84e89428c48f575ee062697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=83=81=EB=B9=88?= Date: Tue, 11 Aug 2026 17:32:02 +0900 Subject: [PATCH 2/2] test: derive taxonomy/post-detail assertions from content instead of hardcoding Given post-fixture (#72, #73) fallout, tests/lists-taxonomy.test.tsx, tests/post-detail.test.tsx, and tests/scaffold.test.ts hardcoded a specific slug, title, date, category, and tag pulled straight from content/posts. Every new or renamed post forced a matching test edit. Derive the expected slug/title/date/category/tag/TOC-visibility from getAllPosts()/getCategoryIndex()/getTagIndex() at test time instead, so writing a post no longer requires touching these files. Verified by temporarily adding a scratch post and confirming the suite still passes unmodified. Co-Authored-By: Claude Sonnet 5 --- tests/lists-taxonomy.test.tsx | 38 +++++++++++++++++--------- tests/post-detail.test.tsx | 50 ++++++++++++++++++++++++++--------- tests/scaffold.test.ts | 21 ++++++++++++--- 3 files changed, 80 insertions(+), 29 deletions(-) diff --git a/tests/lists-taxonomy.test.tsx b/tests/lists-taxonomy.test.tsx index 2cd91a4..64ed2c4 100644 --- a/tests/lists-taxonomy.test.tsx +++ b/tests/lists-taxonomy.test.tsx @@ -51,10 +51,17 @@ describe("blog lists and taxonomy", () => { }) it("Given static export When generating taxonomy params Then category and tag params are fixed", () => { + const [post] = getAllPosts() + const [tag] = post?.tags ?? [] + + if (post === undefined || tag === undefined) { + throw new Error("Expected at least one published post with a tag") + } + expect(categoryDynamicParams).toBe(false) expect(tagDynamicParams).toBe(false) - expect(generateCategoryStaticParams()).toContainEqual({ category: "engineering" }) - expect(generateTagStaticParams()).toContainEqual({ tag: "markdown" }) + expect(generateCategoryStaticParams()).toContainEqual({ category: post.category }) + expect(generateTagStaticParams()).toContainEqual({ tag }) }) it("Given list routes When rendering Then posts page shows Korean archive copy and pagination", () => { @@ -66,24 +73,31 @@ describe("blog lists and taxonomy", () => { }) it("Given taxonomy routes When rendering Then list and detail pages expose counts and matching posts", async () => { + const [category] = getCategoryIndex() + const [tag] = getTagIndex() + + if (category === undefined || tag === undefined) { + throw new Error("Expected at least one category and tag") + } + const categoriesMarkup = renderToStaticMarkup(createElement(CategoriesPage)) const tagsMarkup = renderToStaticMarkup(createElement(TagsPage)) const categoryPage = await CategoryPage({ - params: Promise.resolve({ category: "engineering" }), + params: Promise.resolve({ category: category.name }), }) - const tagPage = await TagPage({ params: Promise.resolve({ tag: "markdown" }) }) + const tagPage = await TagPage({ params: Promise.resolve({ tag: tag.name }) }) const categoryMarkup = renderToStaticMarkup(categoryPage) const tagMarkup = renderToStaticMarkup(tagPage) expect(categoriesMarkup).toContain("카테고리") - expect(categoriesMarkup).toContain("engineering") - expect(categoriesMarkup).toContain("2개") + expect(categoriesMarkup).toContain(category.name) + expect(categoriesMarkup).toContain(`${category.count}개`) expect(tagsMarkup).toContain("태그") - expect(tagsMarkup).toContain("markdown") - expect(tagsMarkup).toContain("2개") - expect(categoryMarkup).toContain("engineering") - expect(categoryMarkup).toContain("2개 글") - expect(tagMarkup).toContain("markdown") - expect(tagMarkup).toContain("2개 글") + expect(tagsMarkup).toContain(tag.name) + expect(tagsMarkup).toContain(`${tag.count}개`) + expect(categoryMarkup).toContain(category.name) + expect(categoryMarkup).toContain(`${category.count}개 글`) + expect(tagMarkup).toContain(tag.name) + expect(tagMarkup).toContain(`${tag.count}개 글`) }) }) diff --git a/tests/post-detail.test.tsx b/tests/post-detail.test.tsx index 08ed1e1..76c867b 100644 --- a/tests/post-detail.test.tsx +++ b/tests/post-detail.test.tsx @@ -3,9 +3,14 @@ import { describe, expect, it } from "vitest" import PostPage, { dynamicParams, generateStaticParams } from "../src/app/(site)/posts/[slug]/page" import { siteConfig } from "../src/config/site" import { shouldRenderTableOfContents } from "../src/features/post-toc/toc-policy" -import type { TableOfContentsItem } from "../src/lib/markdown" +import { renderMarkdown, type TableOfContentsItem } from "../src/lib/markdown" import { getAllPosts } from "../src/lib/posts" +const postDateFormat = new Intl.DateTimeFormat(siteConfig.language, { + dateStyle: "long", + timeZone: "UTC", +}) + describe("post detail reading experience", () => { it("Given published posts When generating static params Then every public slug is emitted", () => { const generatedSlugs = generateStaticParams() @@ -19,27 +24,46 @@ describe("post detail reading experience", () => { expect(generatedSlugs).toEqual(publishedSlugs) }) - it("Given a post without headings When rendering detail Then it shows prose, linked taxonomy, and suppresses the TOC", async () => { - const page = await PostPage({ - params: Promise.resolve({ slug: "markdown-posts-as-build-snapshot" }), - }) + it("Given a published post When rendering detail Then it shows prose, linked taxonomy, and TOC per policy", async () => { + const [post] = getAllPosts() + const [tag] = post?.tags ?? [] + + if (post === undefined || tag === undefined) { + throw new Error("Expected at least one published post with a tag") + } + + const page = await PostPage({ params: Promise.resolve({ slug: post.slug }) }) const markup = renderToStaticMarkup(page) + const readingContent = await renderMarkdown(post.content, { + sourcePath: `content/posts/${post.slug}.md`, + }) + const expectsToc = shouldRenderTableOfContents({ + siteEnabled: siteConfig.toc.enabled, + postToc: post.toc, + minHeadings: siteConfig.toc.minHeadings, + toc: readingContent.toc, + }) expect(markup).toContain("prose") - expect(markup).toContain('href="/categories/engineering/"') - expect(markup).toContain('href="/tags/markdown/"') - expect(markup).not.toContain('data-slot="sheet-trigger"') - expect(markup).not.toContain('aria-label="목차"') + expect(markup).toContain(`href="/categories/${post.category}/"`) + expect(markup).toContain(`href="/tags/${tag}/"`) + expect(markup.includes('data-slot="sheet-trigger"')).toBe(expectsToc) + expect(markup.includes('aria-label="목차"')).toBe(expectsToc) expect(countArticleLandmarks(markup)).toBe(1) }) it("Given a post date When rendering meta Then displays it in Korean while keeping the machine-readable value", async () => { - const page = await PostPage({ - params: Promise.resolve({ slug: "markdown-posts-as-build-snapshot" }), - }) + const [post] = getAllPosts() + + if (post === undefined) { + throw new Error("Expected at least one published post") + } + + const page = await PostPage({ params: Promise.resolve({ slug: post.slug }) }) const markup = renderToStaticMarkup(page) + const expectedDate = postDateFormat.format(new Date(`${post.date}T00:00:00Z`)) - expect(markup).toContain('') + expect(markup).toContain(``) }) it("Given an invalid slug When rendering detail Then it renders not found instead of crashing", async () => { diff --git a/tests/scaffold.test.ts b/tests/scaffold.test.ts index 29abefd..4bf2f29 100644 --- a/tests/scaffold.test.ts +++ b/tests/scaffold.test.ts @@ -6,6 +6,7 @@ import HomePage from "../src/app/(site)/page" import PostPage, { dynamicParams } from "../src/app/(site)/posts/[slug]/page" import NotFoundPage from "../src/app/not-found" import { PostTags } from "../src/components/post-tags" +import { getAllPosts } from "../src/lib/posts" describe("static Next.js scaffold", () => { it("keeps the production build static-exportable with unoptimized images", () => { @@ -27,6 +28,12 @@ describe("static Next.js scaffold", () => { }) it("Given the homepage route When rendering content Then it keeps the expected ordered sections and posts", () => { + const [firstPost] = getAllPosts() + + if (firstPost === undefined) { + throw new Error("Expected at least one published post") + } + const markup = renderToStaticMarkup(createElement(HomePage)) const homeTitleIndex = markup.indexOf('id="home-title">정적 Markdown 기술 블로그') const recentPostsIndex = markup.indexOf('id="recent-posts-title">글') @@ -36,7 +43,7 @@ describe("static Next.js scaffold", () => { expect(homeTitleIndex).toBeGreaterThanOrEqual(0) expect(homeTitleIndex).toBeLessThan(recentPostsIndex) expect(recentPostsIndex).toBeLessThan(browseIndex) - expect(markup).toContain("Markdown Posts를 빌드 스냅샷으로 보기") + expect(markup).toContain(firstPost.title) expect(markup).not.toContain("Launching True Log") }) @@ -50,15 +57,21 @@ describe("static Next.js scaffold", () => { }) it("Given a post detail page When rendering local content Then it returns one labelled article with title and body", async () => { + const [post] = getAllPosts() + + if (post === undefined) { + throw new Error("Expected at least one published post") + } + const page = await PostPage({ - params: Promise.resolve({ slug: "markdown-posts-as-build-snapshot" }), + params: Promise.resolve({ slug: post.slug }), }) const markup = renderToStaticMarkup(page) const articleTags = markup.match(/]*aria-labelledby="post-title"[^>]*>/) - expect(markup).toContain('id="post-title">Markdown Posts를 빌드 스냅샷으로 보기') - expect(markup).toContain("처음에는 캐시가 없었다") + expect(markup).toContain(`id="post-title">${post.title}`) + expect(markup).toContain(post.description) }) })