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
36 changes: 36 additions & 0 deletions content/posts/true-log-project-introduction.md
Original file line number Diff line number Diff line change
@@ -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/<slug>.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 자체가 그 기록의 첫 번째 대상이다.
38 changes: 26 additions & 12 deletions tests/lists-taxonomy.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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("1개")
expect(categoriesMarkup).toContain(category.name)
expect(categoriesMarkup).toContain(`${category.count}개`)
expect(tagsMarkup).toContain("태그")
expect(tagsMarkup).toContain("markdown")
expect(tagsMarkup).toContain("1개")
expect(categoryMarkup).toContain("engineering")
expect(categoryMarkup).toContain("1개 글")
expect(tagMarkup).toContain("markdown")
expect(tagMarkup).toContain("1개 글")
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}개 글`)
})
})
50 changes: 37 additions & 13 deletions tests/post-detail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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('<time dateTime="2026-08-10">2026년 8월 10일</time>')
expect(markup).toContain(`<time dateTime="${post.date}">${expectedDate}</time>`)
})

it("Given an invalid slug When rendering detail Then it renders not found instead of crashing", async () => {
Expand Down
21 changes: 17 additions & 4 deletions tests/scaffold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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">글')
Expand All @@ -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")
})

Expand All @@ -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(/<article\b/g) ?? []

expect(articleTags).toHaveLength(1)
expect(markup).toMatch(/<article\b[^>]*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)
})
})