문서 / docs/superpowers/plans/2026-08-17-public-docs-browser.md

공개 문서 조회 구현 계획

저장소 문서를 공개 경로에서 읽게 만든 구현 계획.

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 저장소의 마크다운 문서 34편을 /docs 경로에서 읽을 수 있게 하고, 공개해서는 안 되는 값이 빌드를 통과하지 못하게 막는다.

Architecture: 기존 근거 파이프라인과 대칭 구조다. 명시적 매니페스트가 공개 대상을 선언하고, 빌드 앞단의 scripts/verify-docs.ts가 원본을 읽어 안전 검사·가림·HTML 변환을 거친 스냅샷 content/generated/docs.public.json을 만든다. 웹 런타임은 그 스냅샷만 읽으므로 원본 저장소를 직접 읽지 않는다.

Tech Stack: Next.js 16 App Router, React 19, TypeScript, node:test + tsx(단위·통합), Playwright(브라우저), markdown-it(빌드 전용 devDependency)

Global Constraints

  • 설계 문서: docs/superpowers/specs/2026-08-17-public-docs-browser-design.md
  • 웹 런타임은 원본 저장소를 직접 읽지 않는다. 런타임이 읽는 것은 content/generated/docs.public.json 하나뿐이다. (헌법 원칙 V)
  • 런타임 의존성은 next, react, react-dom 셋으로 유지한다. markdown-it은 반드시 devDependencies에 넣는다.
  • 공개 대상은 글로브가 아니라 content/docs-manifest.ts에 선언한 항목만이다.
  • 절대 서버 경로와 사설 주소는 가린다. 개인키·Authorization 헤더·Bearer 토큰·비밀값 할당·데이터베이스 URL·이메일·허용 목록 밖 외부 URL은 가리지 않고 빌드를 실패시킨다.
  • 허용 URL 원본은 https://harness.insapien.co.kr 하나로 시작한다.
  • 모든 페이지는 정적 생성하고 JavaScript 없이 읽을 수 있어야 한다. (헌법 원칙 VI)
  • 테스트를 구현보다 먼저 쓰고 RED를 확인한 뒤 GREEN으로 만든다. (헌법 원칙 III)
  • 문서 안의 # 제목은 페이지 h1과 충돌하므로 한 단계씩 낮춰 h2부터 시작한다.
  • 커밋 메시지는 한국어로 쓰고 무엇을 왜 바꿨는지 남긴다.

시작 전 상태: npm run verify:manualunknown public route: /docs 하나로 실패한다. Task 1이 이것을 GREEN으로 만든다.


Task 1: /docs를 공개 경로로 선언한다

문서와 게이트를 먼저 고친다. 헌법 원칙 II가 명세 수정이 구현을 선행하도록 요구한다. 이 시점에는 페이지가 없으므로 헤더 메뉴(content/site.ts)는 아직 건드리지 않는다. 링크가 깨진 메뉴를 먼저 만들지 않기 위해서다.

Files:

  • Modify: .specify/memory/constitution.md (기술 및 운영 제약의 경로 목록)
  • Modify: scripts/verify-manual.mjs:13-21 (PUBLIC_ROUTES)
  • Modify: docs/manual-impact.md

Interfaces:

  • Consumes: 없음

  • Produces: PUBLIC_ROUTES/docs가 포함되어 이후 모든 문서가 /docs 경로를 언급할 수 있다.

  • [ ] Step 1: 실패를 먼저 확인한다

Run: npm run verify:manual Expected: FAIL — docs/superpowers/specs/2026-08-17-public-docs-browser-design.md: unknown public route: /docs

  • [ ] Step 2: 헌법의 경로 제약을 다섯 개로 개정한다

.specify/memory/constitution.md의 기술 및 운영 제약에서 아래 줄을 찾는다.

- 공개 사이트는 한국어 개발자 독자를 우선하며 `/`, `/guide`, `/5240lab`,
  `/roadmap` 네 개의 안정적인 경로를 제공한다.

다음으로 바꾼다.

- 공개 사이트는 한국어 개발자 독자를 우선하며 `/`, `/guide`, `/5240lab`,
  `/roadmap`, `/docs` 다섯 개의 안정적인 경로를 제공한다. `/docs`는 매니페스트에
  선언한 문서만 공개하며, 빌드 시 검증된 스냅샷만 런타임에 전달한다.
  • [ ] Step 3: 매뉴얼 게이트의 공개 경로 목록에 /docs를 더한다

scripts/verify-manual.mjsPUBLIC_ROUTES를 수정한다.

const PUBLIC_ROUTES = new Set([
  "/",
  "/guide",
  "/5240lab",
  "/roadmap",
  "/docs",
  "/api/health",
  "/robots.txt",
  "/sitemap.xml",
]);
  • [ ] Step 4: 매뉴얼 영향을 기록한다

docs/manual-impact.md 끝에 다음 절을 추가한다.

## 공개 문서 조회 (2026-08-17)

- manual-impact: yes
- 근거: 공개 경로가 네 개에서 다섯 개로 늘고 `/docs` 아래 문서 상세 경로가 생긴다.
- 조치: 운영 매뉴얼의 외부 스모크 경로 목록에 `/docs`를 더하고, 공개 대상 문서는
  `content/docs-manifest.ts`에 선언한 것만이라는 규칙을 운영 매뉴얼에 남긴다.
- 공개 경로 확인: `/docs` 목록과 `/docs/<묶음>/<문서>` 상세가 모두 200으로 응답해야 한다.
  • [ ] Step 5: 게이트가 통과하는지 확인한다

Run: npm run verify:manual Expected: PASS — Manual contract verified: commands, links, routes, public URLs, and leak checks passed.

  • [ ] Step 6: 커밋
git add .specify/memory/constitution.md scripts/verify-manual.mjs docs/manual-impact.md
git commit -m "docs(harness): 공개 경로에 /docs를 더하고 헌법과 매뉴얼 영향을 맞춘다"

Task 2: 문서 공개 안전 정책

가리는 것과 실패시키는 것을 나누는 모듈이다. 기존 lib/evidence-validator.tsUNSAFE_PUBLIC_PATTERNS는 모든 URL을 금지하므로 그대로 쓸 수 없다. 문서용 정책을 따로 둔다.

Files:

  • Create: lib/docs-safety.ts
  • Test: tests/unit/docs-safety.test.ts

Interfaces:

  • Consumes: 없음

  • Produces:

    • class DocSafetyError extends Error
    • interface DocRedaction { kind: "absolute-path" | "private-address"; original: string }
    • interface DocRedactionResult { text: string; redactions: DocRedaction[] }
    • function redactDocText(text: string): DocRedactionResult
    • function assertDocPublicSafe(text: string, docId: string): void
    • const ALLOWED_DOC_ORIGINS: readonly string[]
  • [ ] Step 1: 실패하는 테스트를 쓴다

Create tests/unit/docs-safety.test.ts:

import assert from "node:assert/strict";
import { describe, it } from "node:test";

import {
  ALLOWED_DOC_ORIGINS,
  assertDocPublicSafe,
  DocSafetyError,
  redactDocText,
} from "../../lib/docs-safety";

describe("redactDocText", () => {
  it("절대 서버 경로를 프로젝트 루트 표기로 바꾼다", () => {
    const result = redactDocText("프로젝트 루트: `<프로젝트 루트>`");

    assert.equal(result.text, "프로젝트 루트: `<프로젝트 루트>`");
    assert.deepEqual(result.redactions, [
      { kind: "absolute-path", original: "<프로젝트 루트>" },
    ]);
  });

  it("사설 주소와 포트를 로컬 표기로 바꾼다", () => {
    const result = redactDocText("후보 검사: <로컬>/api/health");

    assert.equal(result.text, "후보 검사: <로컬>/api/health");
    assert.deepEqual(result.redactions, [
      { kind: "private-address", original: "<로컬>" },
    ]);
  });

  it("허용 원본의 주소는 건드리지 않는다", () => {
    const input = "외부 스모크: https://harness.insapien.co.kr/api/health";

    assert.equal(redactDocText(input).text, input);
  });

  it("가릴 것이 없으면 원문을 그대로 돌려준다", () => {
    const input = "제목 폭은 em으로 잡는다.";
    const result = redactDocText(input);

    assert.equal(result.text, input);
    assert.deepEqual(result.redactions, []);
  });
});

describe("assertDocPublicSafe", () => {
  it("가림으로 처리된 문서는 통과시킨다", () => {
    assert.doesNotThrow(() => {
      assertDocPublicSafe("배포 주소는 https://harness.insapien.co.kr 이다.", "operations");
    });
  });

  it("개인키 표식을 거부한다", () => {
    assert.throws(
      () => assertDocPublicSafe("-----BEGIN PRIVATE KEY-----", "operations"),
      (error: unknown) => error instanceof DocSafetyError && /개인키/u.test((error as Error).message),
    );
  });

  it("비밀값 할당을 거부한다", () => {
    assert.throws(
      () => assertDocPublicSafe("DATABASE_URL=postgres://user:pw@host/db", "operations"),
      DocSafetyError,
    );
  });

  it("이메일 주소를 거부한다", () => {
    assert.throws(() => assertDocPublicSafe("문의: someone@example.com", "handoff"), DocSafetyError);
  });

  it("허용 목록 밖의 외부 주소를 거부한다", () => {
    assert.throws(() => assertDocPublicSafe("참고: https://example.com/a", "research"), DocSafetyError);
  });

  it("가리지 않은 절대 경로가 남아 있으면 거부한다", () => {
    assert.throws(() => assertDocPublicSafe("루트는 <프로젝트 루트> 이다.", "quickstart"), DocSafetyError);
  });

  it("오류 메시지에 문서 ID를 담는다", () => {
    assert.throws(
      () => assertDocPublicSafe("문의: someone@example.com", "handoff"),
      (error: unknown) => error instanceof DocSafetyError && /handoff/u.test((error as Error).message),
    );
  });
});

describe("ALLOWED_DOC_ORIGINS", () => {
  it("사이트 자기 주소 하나로 시작한다", () => {
    assert.deepEqual([...ALLOWED_DOC_ORIGINS], ["https://harness.insapien.co.kr"]);
  });
});
  • [ ] Step 2: RED를 확인한다

Run: node --import tsx --test tests/unit/docs-safety.test.ts Expected: FAIL — Cannot find module '../../lib/docs-safety'

  • [ ] Step 3: 최소 구현을 쓴다

Create lib/docs-safety.ts:

export class DocSafetyError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "DocSafetyError";
  }
}

export const ALLOWED_DOC_ORIGINS = ["https://harness.insapien.co.kr"] as const;

export interface DocRedaction {
  kind: "absolute-path" | "private-address";
  original: string;
}

export interface DocRedactionResult {
  text: string;
  redactions: DocRedaction[];
}

const PRIVATE_ADDRESS_PATTERN =
  /https?:\/\/(?:localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})(?::\d{2,5})?/gu;

const ABSOLUTE_PATH_PATTERN =
  /\/(?:home|Users|root|etc|opt|srv|usr|var|tmp)(?:\/[A-Za-z0-9._-]+)*/gu;

const FAILING_PATTERNS: ReadonlyArray<[RegExp, string]> = [
  [/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/iu, "개인키 표식"],
  [/\bAuthorization\s*:\s*(?:Bearer|Basic)\s+\S+/iu, "Authorization 헤더"],
  [/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/u, "Bearer 토큰"],
  [/\b(?:password|passwd|token|secret|api[_-]?key|database_url)\s*=\s*\S+/iu, "비밀값 할당"],
  [/\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/\S+/iu, "데이터베이스 URL"],
  [/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/iu, "이메일 주소"],
];

export function redactDocText(text: string): DocRedactionResult {
  const redactions: DocRedaction[] = [];

  const withoutPrivateAddresses = text.replace(PRIVATE_ADDRESS_PATTERN, (match) => {
    redactions.push({ kind: "private-address", original: match });
    return "<로컬>";
  });

  const withoutAbsolutePaths = withoutPrivateAddresses.replace(ABSOLUTE_PATH_PATTERN, (match) => {
    redactions.push({ kind: "absolute-path", original: match });
    return "<프로젝트 루트>";
  });

  return { text: withoutAbsolutePaths, redactions };
}

export function assertDocPublicSafe(text: string, docId: string): void {
  for (const [pattern, label] of FAILING_PATTERNS) {
    if (pattern.test(text)) {
      throw new DocSafetyError(`${docId}: 공개할 수 없는 값(${label})이 있습니다.`);
    }
  }

  for (const match of text.matchAll(/https?:\/\/[^\s)"'`<>]+/gu)) {
    const origin = new URL(match[0]).origin;
    if (!ALLOWED_DOC_ORIGINS.includes(origin as (typeof ALLOWED_DOC_ORIGINS)[number])) {
      throw new DocSafetyError(`${docId}: 허용 목록에 없는 외부 주소(${origin})가 있습니다.`);
    }
  }

  const leftoverPath = text.match(ABSOLUTE_PATH_PATTERN);
  if (leftoverPath) {
    throw new DocSafetyError(`${docId}: 가려지지 않은 절대 경로(${leftoverPath[0]})가 있습니다.`);
  }
}
  • [ ] Step 4: GREEN을 확인한다

Run: node --import tsx --test tests/unit/docs-safety.test.ts Expected: PASS — 12 tests

  • [ ] Step 5: 커밋
git add lib/docs-safety.ts tests/unit/docs-safety.test.ts
git commit -m "feat(harness): 문서 공개 안전 정책을 더한다"

Task 3: 마크다운 변환기

빌드 시점에만 도는 변환기다. markdown-it은 기본값이 html: false라 원시 HTML을 이스케이프한다. 별도 위생 처리기가 필요 없다.

Files:

  • Create: lib/docs-markdown.ts
  • Test: tests/unit/docs-markdown.test.ts
  • Modify: package.json (devDependencies)

Interfaces:

  • Consumes: 없음

  • Produces:

    • interface DocHeading { id: string; text: string; level: 2 | 3 | 4 }
    • interface DocRender { html: string; toc: DocHeading[] }
    • function renderDocMarkdown(markdown: string): DocRender
    • function extractDocTitle(markdown: string): string — 첫 # 제목. 없으면 DocSafetyError가 아니라 Error를 던진다.
  • [ ] Step 1: 변환기를 빌드 전용 의존성으로 더한다

npm install --save-dev markdown-it@15 @types/markdown-it@14

package.jsondependenciesnext, react, react-dom 셋 그대로인지 확인한다.

  • [ ] Step 2: 실패하는 테스트를 쓴다

Create tests/unit/docs-markdown.test.ts:

import assert from "node:assert/strict";
import { describe, it } from "node:test";

import { extractDocTitle, renderDocMarkdown } from "../../lib/docs-markdown";

describe("renderDocMarkdown", () => {
  it("문서의 제목을 한 단계씩 낮춘다", () => {
    const { html } = renderDocMarkdown("# 문서 제목\n\n## 절 제목\n\n### 항 제목\n");

    assert.match(html, /<h2 id="문서-제목">문서 제목<\/h2>/u);
    assert.match(html, /<h3 id="절-제목">절 제목<\/h3>/u);
    assert.match(html, /<h4 id="항-제목">항 제목<\/h4>/u);
  });

  it("낮춘 제목을 목차로 모은다", () => {
    const { toc } = renderDocMarkdown("# 제목\n\n## 배경\n\n### 세부\n");

    assert.deepEqual(toc, [
      { id: "제목", text: "제목", level: 2 },
      { id: "배경", text: "배경", level: 3 },
      { id: "세부", text: "세부", level: 4 },
    ]);
  });

  it("같은 제목이 반복되면 앵커에 번호를 붙인다", () => {
    const { toc } = renderDocMarkdown("## 검증\n\n## 검증\n");

    assert.deepEqual(
      toc.map((heading) => heading.id),
      ["검증", "검증-2"],
    );
  });

  it("원시 HTML을 실행 가능한 태그로 내보내지 않는다", () => {
    const { html } = renderDocMarkdown("<script>alert(1)</script>\n");

    assert.doesNotMatch(html, /<script>/u);
    assert.match(html, /&lt;script&gt;/u);
  });

  it("표와 코드 블록을 변환한다", () => {
    const { html } = renderDocMarkdown("| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```bash\nls\n```\n");

    assert.match(html, /<table>/u);
    assert.match(html, /<code[^>]*>ls\n<\/code>/u);
  });

  it("h5 아래는 더 낮추지 않고 h6에서 멈춘다", () => {
    const { html } = renderDocMarkdown("##### 다섯\n\n###### 여섯\n");

    assert.match(html, /<h6[^>]*>다섯<\/h6>/u);
    assert.match(html, /<h6[^>]*>여섯<\/h6>/u);
  });
});

describe("extractDocTitle", () => {
  it("첫 번째 최상위 제목을 뽑는다", () => {
    assert.equal(extractDocTitle("# 운영 매뉴얼\n\n본문\n"), "운영 매뉴얼");
  });

  it("최상위 제목이 없으면 거부한다", () => {
    assert.throws(() => extractDocTitle("본문만 있다\n"), /최상위 제목/u);
  });
});
  • [ ] Step 3: RED를 확인한다

Run: node --import tsx --test tests/unit/docs-markdown.test.ts Expected: FAIL — Cannot find module '../../lib/docs-markdown'

  • [ ] Step 4: 최소 구현을 쓴다

Create lib/docs-markdown.ts:

import MarkdownIt from "markdown-it";

export interface DocHeading {
  id: string;
  text: string;
  level: 2 | 3 | 4;
}

export interface DocRender {
  html: string;
  toc: DocHeading[];
}

function slugify(text: string): string {
  return text
    .trim()
    .toLowerCase()
    .replace(/[^\p{Letter}\p{Number}\s-]/gu, "")
    .replace(/\s+/gu, "-");
}

export function extractDocTitle(markdown: string): string {
  const match = markdown.match(/^#\s+(.+)$/mu);
  if (!match) {
    throw new Error("문서에 최상위 제목이 없습니다.");
  }

  return match[1].trim();
}

export function renderDocMarkdown(markdown: string): DocRender {
  const md = new MarkdownIt({ html: false, linkify: false, typographer: false });
  const tokens = md.parse(markdown, {});
  const toc: DocHeading[] = [];
  const usedIds = new Map<string, number>();

  for (const [index, token] of tokens.entries()) {
    if (token.type !== "heading_open") continue;

    const sourceLevel = Number(token.tag.slice(1));
    const targetLevel = Math.min(sourceLevel + 1, 6);
    token.tag = `h${targetLevel}`;
    const closing = tokens[index + 2];
    if (closing?.type === "heading_close") {
      closing.tag = `h${targetLevel}`;
    }

    const text = tokens[index + 1]?.content ?? "";
    const base = slugify(text);
    const seen = usedIds.get(base) ?? 0;
    usedIds.set(base, seen + 1);
    const id = seen === 0 ? base : `${base}-${seen + 1}`;
    token.attrSet("id", id);

    if (targetLevel <= 4) {
      toc.push({ id, text, level: targetLevel as 2 | 3 | 4 });
    }
  }

  return { html: md.renderer.render(tokens, md.options, {}), toc };
}
  • [ ] Step 5: GREEN을 확인한다

Run: node --import tsx --test tests/unit/docs-markdown.test.ts Expected: PASS — 8 tests

  • [ ] Step 6: 커밋
git add lib/docs-markdown.ts tests/unit/docs-markdown.test.ts package.json package-lock.json
git commit -m "feat(harness): 빌드 전용 마크다운 변환기를 더한다"

Task 4: 매니페스트와 스키마

공개 대상을 손으로 선언한다. 제목은 문서의 최상위 제목에서 가져오므로 매니페스트에는 적지 않는다. 제목이 두 곳에서 어긋나는 일을 막기 위해서다.

Files:

  • Create: lib/docs-schema.ts
  • Create: content/docs-manifest.ts
  • Test: tests/unit/docs-schema.test.ts

Interfaces:

  • Consumes: 없음

  • Produces:

    • const DOC_GROUPS: readonly { id: DocGroupId; label: string; description: string }[]
    • type DocGroupId = "design" | "specs" | "operations" | "standards"
    • interface DocManifestEntry { group: DocGroupId; slug: string; path: string; summary: string }
    • interface DocsManifest { entries: DocManifestEntry[] }
    • function parseDocsManifest(value: unknown): DocsManifest
    • interface DocPublic { group: DocGroupId; slug: string; title: string; summary: string; sourcePath: string; html: string; toc: DocHeading[] }
    • interface DocsPublicSnapshot { generatedFrom: string; docs: DocPublic[] }
    • function parseDocsPublicSnapshot(value: unknown): DocsPublicSnapshot
    • const docsManifest: DocsManifest (content/docs-manifest.ts)
  • [ ] Step 1: 실패하는 테스트를 쓴다

Create tests/unit/docs-schema.test.ts:

import assert from "node:assert/strict";
import { describe, it } from "node:test";

import { docsManifest } from "../../content/docs-manifest";
import { DOC_GROUPS, parseDocsManifest, parseDocsPublicSnapshot } from "../../lib/docs-schema";

describe("parseDocsManifest", () => {
  it("올바른 매니페스트를 통과시킨다", () => {
    const parsed = parseDocsManifest({
      entries: [
        { group: "design", slug: "public-docs-browser", path: "docs/a.md", summary: "요약" },
      ],
    });

    assert.equal(parsed.entries.length, 1);
  });

  it("알 수 없는 묶음을 거부한다", () => {
    assert.throws(
      () => parseDocsManifest({ entries: [{ group: "unknown", slug: "a", path: "a.md", summary: "요약" }] }),
      /묶음/u,
    );
  });

  it("kebab-case가 아닌 슬러그를 거부한다", () => {
    assert.throws(
      () => parseDocsManifest({ entries: [{ group: "design", slug: "Not_Kebab", path: "a.md", summary: "요약" }] }),
      /슬러그/u,
    );
  });

  it("묶음 안에서 슬러그 중복을 거부한다", () => {
    assert.throws(
      () =>
        parseDocsManifest({
          entries: [
            { group: "design", slug: "a", path: "a.md", summary: "요약" },
            { group: "design", slug: "a", path: "b.md", summary: "요약" },
          ],
        }),
      /중복/u,
    );
  });

  it("마크다운이 아닌 경로를 거부한다", () => {
    assert.throws(
      () => parseDocsManifest({ entries: [{ group: "design", slug: "a", path: "a.txt", summary: "요약" }] }),
      /\.md/u,
    );
  });

  it("프로젝트 밖을 가리키는 경로를 거부한다", () => {
    assert.throws(
      () => parseDocsManifest({ entries: [{ group: "design", slug: "a", path: "../secret.md", summary: "요약" }] }),
      /프로젝트 안/u,
    );
  });

  it("빈 요약을 거부한다", () => {
    assert.throws(
      () => parseDocsManifest({ entries: [{ group: "design", slug: "a", path: "a.md", summary: "" }] }),
      /요약/u,
    );
  });
});

describe("docsManifest", () => {
  it("네 묶음을 모두 채운다", () => {
    const groups = new Set(docsManifest.entries.map((entry) => entry.group));

    assert.deepEqual([...groups].sort(), ["design", "operations", "specs", "standards"]);
  });

  it("설계 문서에 이번 설계가 들어 있다", () => {
    const slugs = docsManifest.entries.map((entry) => entry.slug);

    assert.ok(slugs.includes("public-docs-browser"));
  });

  it("스키마를 통과한다", () => {
    assert.doesNotThrow(() => parseDocsManifest(docsManifest));
  });

  it("DOC_GROUPS에 선언한 묶음만 쓴다", () => {
    const declared = new Set(DOC_GROUPS.map((group) => group.id));

    for (const entry of docsManifest.entries) {
      assert.ok(declared.has(entry.group), `${entry.slug}의 묶음이 선언되지 않았습니다.`);
    }
  });
});

describe("parseDocsPublicSnapshot", () => {
  it("올바른 스냅샷을 통과시킨다", () => {
    const parsed = parseDocsPublicSnapshot({
      generatedFrom: "content/docs-manifest.ts",
      docs: [
        {
          group: "design",
          slug: "public-docs-browser",
          title: "공개 문서 조회 설계",
          summary: "요약",
          sourcePath: "docs/superpowers/specs/2026-08-17-public-docs-browser-design.md",
          html: "<h2 id=\"a\">a</h2>",
          toc: [{ id: "a", text: "a", level: 2 }],
        },
      ],
    });

    assert.equal(parsed.docs.length, 1);
  });

  it("본문이 빈 문서를 거부한다", () => {
    assert.throws(
      () =>
        parseDocsPublicSnapshot({
          generatedFrom: "content/docs-manifest.ts",
          docs: [
            {
              group: "design",
              slug: "a",
              title: "제목",
              summary: "요약",
              sourcePath: "a.md",
              html: "",
              toc: [],
            },
          ],
        }),
      /본문/u,
    );
  });
});
  • [ ] Step 2: RED를 확인한다

Run: node --import tsx --test tests/unit/docs-schema.test.ts Expected: FAIL — Cannot find module '../../lib/docs-schema'

  • [ ] Step 3: 스키마를 쓴다

Create lib/docs-schema.ts:

import type { DocHeading } from "./docs-markdown";

export class DocsSchemaError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "DocsSchemaError";
  }
}

export const DOC_GROUPS = [
  { id: "design", label: "설계", description: "브레인스토밍으로 확정한 설계 문서" },
  { id: "specs", label: "명세·계획", description: "기능정의서, 기술계획, 작업목록과 계약" },
  { id: "operations", label: "운영·검증", description: "운영 절차와 검증·배포 기록" },
  { id: "standards", label: "기준문서", description: "헌법과 디자인 시스템" },
] as const;

export type DocGroupId = (typeof DOC_GROUPS)[number]["id"];

const GROUP_IDS = new Set<string>(DOC_GROUPS.map((group) => group.id));
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;

export interface DocManifestEntry {
  group: DocGroupId;
  slug: string;
  path: string;
  summary: string;
}

export interface DocsManifest {
  entries: DocManifestEntry[];
}

export interface DocPublic {
  group: DocGroupId;
  slug: string;
  title: string;
  summary: string;
  sourcePath: string;
  html: string;
  toc: DocHeading[];
}

export interface DocsPublicSnapshot {
  generatedFrom: string;
  docs: DocPublic[];
}

function asRecord(value: unknown, label: string): Record<string, unknown> {
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
    throw new DocsSchemaError(`${label}은(는) 객체여야 합니다.`);
  }

  return value as Record<string, unknown>;
}

function asNonEmptyString(value: unknown, label: string): string {
  if (typeof value !== "string" || value.trim() === "") {
    throw new DocsSchemaError(`${label}은(는) 비어 있지 않은 문자열이어야 합니다.`);
  }

  return value;
}

export function parseDocsManifest(value: unknown): DocsManifest {
  const record = asRecord(value, "문서 매니페스트");
  const rawEntries = record.entries;
  if (!Array.isArray(rawEntries) || rawEntries.length === 0) {
    throw new DocsSchemaError("문서 매니페스트에는 항목이 하나 이상 있어야 합니다.");
  }

  const seen = new Set<string>();
  const entries = rawEntries.map((rawEntry, index) => {
    const entry = asRecord(rawEntry, `문서 매니페스트 ${index + 1}번째 항목`);
    const group = asNonEmptyString(entry.group, `${index + 1}번째 항목의 묶음`);
    if (!GROUP_IDS.has(group)) {
      throw new DocsSchemaError(`알 수 없는 묶음 "${group}"입니다.`);
    }

    const slug = asNonEmptyString(entry.slug, `${index + 1}번째 항목의 슬러그`);
    if (!SLUG_PATTERN.test(slug)) {
      throw new DocsSchemaError(`슬러그 "${slug}"은(는) 소문자 kebab-case여야 합니다.`);
    }

    const key = `${group}/${slug}`;
    if (seen.has(key)) {
      throw new DocsSchemaError(`슬러그 "${key}"이(가) 중복되었습니다.`);
    }
    seen.add(key);

    const docPath = asNonEmptyString(entry.path, `${index + 1}번째 항목의 경로`);
    if (!docPath.endsWith(".md")) {
      throw new DocsSchemaError(`경로 "${docPath}"은(는) .md 파일이어야 합니다.`);
    }
    if (docPath.startsWith("/") || docPath.split("/").includes("..")) {
      throw new DocsSchemaError(`경로 "${docPath}"은(는) 프로젝트 안의 상대 경로여야 합니다.`);
    }

    const summary = asNonEmptyString(entry.summary, `${index + 1}번째 항목의 요약`);

    return { group: group as DocGroupId, slug, path: docPath, summary };
  });

  return { entries };
}

export function parseDocsPublicSnapshot(value: unknown): DocsPublicSnapshot {
  const record = asRecord(value, "문서 스냅샷");
  const generatedFrom = asNonEmptyString(record.generatedFrom, "스냅샷 출처");
  const rawDocs = record.docs;
  if (!Array.isArray(rawDocs) || rawDocs.length === 0) {
    throw new DocsSchemaError("문서 스냅샷에는 문서가 하나 이상 있어야 합니다.");
  }

  const docs = rawDocs.map((rawDoc, index) => {
    const doc = asRecord(rawDoc, `${index + 1}번째 문서`);
    const group = asNonEmptyString(doc.group, `${index + 1}번째 문서의 묶음`);
    if (!GROUP_IDS.has(group)) {
      throw new DocsSchemaError(`알 수 없는 묶음 "${group}"입니다.`);
    }

    return {
      group: group as DocGroupId,
      slug: asNonEmptyString(doc.slug, `${index + 1}번째 문서의 슬러그`),
      title: asNonEmptyString(doc.title, `${index + 1}번째 문서의 제목`),
      summary: asNonEmptyString(doc.summary, `${index + 1}번째 문서의 요약`),
      sourcePath: asNonEmptyString(doc.sourcePath, `${index + 1}번째 문서의 원본 경로`),
      html: asNonEmptyString(doc.html, `${index + 1}번째 문서의 본문`),
      toc: (doc.toc ?? []) as DocHeading[],
    };
  });

  return { generatedFrom, docs };
}
  • [ ] Step 4: 매니페스트를 쓴다

Create content/docs-manifest.ts. 34편 전부를 선언한다.

import type { DocsManifest } from "../lib/docs-schema";

export const docsManifest: DocsManifest = {
  entries: [
    // 설계
    { group: "design", slug: "harness-website", path: "docs/superpowers/specs/2026-08-13-harness-website-design.md", summary: "공개 사이트의 정보 구조와 네 경로를 정한 최초 설계." },
    { group: "design", slug: "site-typography-scale", path: "docs/superpowers/specs/2026-08-15-site-typography-scale-design.md", summary: "제목과 본문의 크기 위계를 다시 잡은 설계." },
    { group: "design", slug: "public-docs-browser", path: "docs/superpowers/specs/2026-08-17-public-docs-browser-design.md", summary: "저장소 문서를 공개 경로에서 읽게 만든 설계." },

    // 명세·계획
    { group: "specs", slug: "001-spec", path: "specs/001-harness-public-site/spec.md", summary: "공개 사이트의 사용자 시나리오와 수용 조건." },
    { group: "specs", slug: "001-plan", path: "specs/001-harness-public-site/plan.md", summary: "공개 사이트의 기술 계획과 데이터 흐름." },
    { group: "specs", slug: "001-tasks", path: "specs/001-harness-public-site/tasks.md", summary: "공개 사이트 구현을 단계별 작업으로 나눈 목록." },
    { group: "specs", slug: "001-research", path: "specs/001-harness-public-site/research.md", summary: "공개 사이트 구현 전 조사한 선택지와 근거." },
    { group: "specs", slug: "001-data-model", path: "specs/001-harness-public-site/data-model.md", summary: "근거, 계층, 성숙도 데이터의 구조." },
    { group: "specs", slug: "001-quickstart", path: "specs/001-harness-public-site/quickstart.md", summary: "공개 사이트를 처음부터 검증하는 절차." },
    { group: "specs", slug: "001-checklist-requirements", path: "specs/001-harness-public-site/checklists/requirements.md", summary: "기능정의서 품질 점검표." },
    { group: "specs", slug: "001-contract-evidence-manifest", path: "specs/001-harness-public-site/contracts/evidence-manifest.md", summary: "근거 매니페스트의 계약." },
    { group: "specs", slug: "001-contract-maturity-assessment", path: "specs/001-harness-public-site/contracts/maturity-assessment.md", summary: "성숙도 평가와 재판정의 계약." },
    { group: "specs", slug: "001-contract-public-routes", path: "specs/001-harness-public-site/contracts/public-routes.md", summary: "공개 경로가 지켜야 할 계약." },
    { group: "specs", slug: "002-spec", path: "specs/002-site-typography-scale/spec.md", summary: "타이포그래피 조정의 수용 조건." },
    { group: "specs", slug: "002-plan", path: "specs/002-site-typography-scale/plan.md", summary: "타이포그래피 조정의 기술 계획." },
    { group: "specs", slug: "002-tasks", path: "specs/002-site-typography-scale/tasks.md", summary: "타이포그래피 조정의 작업 목록." },
    { group: "specs", slug: "002-research", path: "specs/002-site-typography-scale/research.md", summary: "타이포그래피 조정 전 조사한 근거." },
    { group: "specs", slug: "002-data-model", path: "specs/002-site-typography-scale/data-model.md", summary: "타이포그래피 토큰의 구조." },
    { group: "specs", slug: "002-quickstart", path: "specs/002-site-typography-scale/quickstart.md", summary: "타이포그래피 변경을 확인하는 절차." },
    { group: "specs", slug: "002-checklist-requirements", path: "specs/002-site-typography-scale/checklists/requirements.md", summary: "타이포그래피 기능정의서 점검표." },
    { group: "specs", slug: "002-contract-typography-ui", path: "specs/002-site-typography-scale/contracts/typography-ui-contract.md", summary: "글자 크기와 반응형 동작의 계약." },

    // 운영·검증
    { group: "operations", slug: "operations", path: "docs/operations.md", summary: "배포, 활성화, 스모크와 사고 대응 절차." },
    { group: "operations", slug: "evidence-maintenance", path: "docs/evidence-maintenance.md", summary: "공개 근거를 더하고 고치는 규칙." },
    { group: "operations", slug: "handoff", path: "docs/handoff.md", summary: "구현 인계와 커밋 게이트." },
    { group: "operations", slug: "implementation-baseline", path: "docs/implementation-baseline.md", summary: "구현 시작 시점의 기준선." },
    { group: "operations", slug: "manual-impact", path: "docs/manual-impact.md", summary: "기능별 매뉴얼 영향과 릴리스 정합성 기록." },
    { group: "operations", slug: "verification-report", path: "docs/verification-report.md", summary: "Phase 6~8 검증 결과." },
    { group: "operations", slug: "deployment-report", path: "docs/deployment-report.md", summary: "Phase 8 배포 검증 결과." },
    { group: "operations", slug: "report-foundation", path: "docs/task-reports/foundation.md", summary: "기반 작업의 RED에서 GREEN까지의 기록." },
    { group: "operations", slug: "report-us1-guide", path: "docs/task-reports/us1-guide.md", summary: "작업 가이드 화면의 구현 기록." },
    { group: "operations", slug: "report-us2-evidence", path: "docs/task-reports/us2-evidence.md", summary: "공개 근거 아틀라스의 구현 기록." },
    { group: "operations", slug: "report-us3-roadmap", path: "docs/task-reports/us3-roadmap.md", summary: "개선 로드맵 화면의 구현 기록." },

    // 기준문서
    { group: "standards", slug: "constitution", path: ".specify/memory/constitution.md", summary: "이 프로젝트가 지키는 여섯 원칙과 품질 게이트." },
    { group: "standards", slug: "design-system", path: "design-system/5240lab-harness/MASTER.md", summary: "색, 간격, 컴포넌트의 기준." },
  ],
};
  • [ ] Step 5: GREEN을 확인한다

Run: node --import tsx --test tests/unit/docs-schema.test.ts Expected: PASS — 13 tests

  • [ ] Step 6: 커밋
git add lib/docs-schema.ts content/docs-manifest.ts tests/unit/docs-schema.test.ts
git commit -m "feat(harness): 공개 문서 매니페스트와 스키마를 더한다"

Task 5: 빌드 스냅샷 생성기

Files:

  • Create: lib/docs-snapshot.ts
  • Create: scripts/verify-docs.ts
  • Create: content/generated/docs.public.json (생성물)
  • Test: tests/unit/docs-snapshot.test.ts
  • Modify: package.json (scripts)

content/generated는 무시 대상이 아니다. content/generated/evidence.public.json이 이미 추적되고 있으므로 생성된 스냅샷도 같이 커밋한다.

Interfaces:

  • Consumes: parseDocsManifest, parseDocsPublicSnapshot, DocsManifest, DocsPublicSnapshot (Task 4), redactDocText, assertDocPublicSafe (Task 2), renderDocMarkdown, extractDocTitle (Task 3)

  • Produces:

    • function buildDocsSnapshot(options: { manifest: DocsManifest; projectRoot: string }): Promise<DocsPublicSnapshot>
    • function writeDocsSnapshot(options: { manifest: DocsManifest; projectRoot: string; outputPath: string }): Promise<DocsPublicSnapshot>
    • npm script verify:docs
  • [ ] Step 1: 실패하는 테스트를 쓴다

Create tests/unit/docs-snapshot.test.ts:

import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, it } from "node:test";

import { buildDocsSnapshot } from "../../lib/docs-snapshot";

async function fixture(files: Record<string, string>): Promise<string> {
  const root = await mkdtemp(path.join(tmpdir(), "docs-snapshot-"));
  for (const [name, body] of Object.entries(files)) {
    const target = path.join(root, name);
    await mkdir(path.dirname(target), { recursive: true });
    await writeFile(target, body, "utf8");
  }

  return root;
}

describe("buildDocsSnapshot", () => {
  it("제목을 문서에서 가져오고 본문을 HTML로 바꾼다", async () => {
    const root = await fixture({ "docs/a.md": "# 운영 매뉴얼\n\n## 배포\n\n본문\n" });
    const snapshot = await buildDocsSnapshot({
      manifest: { entries: [{ group: "operations", slug: "a", path: "docs/a.md", summary: "요약" }] },
      projectRoot: root,
    });

    assert.equal(snapshot.docs[0].title, "운영 매뉴얼");
    assert.match(snapshot.docs[0].html, /<h3 id="배포">배포<\/h3>/u);
    assert.deepEqual(snapshot.docs[0].toc[0], { id: "운영-매뉴얼", text: "운영 매뉴얼", level: 2 });
  });

  it("절대 경로를 가린 뒤 스냅샷에 담는다", async () => {
    const root = await fixture({ "docs/a.md": "# 제목\n\n루트는 `<프로젝트 루트>` 이다.\n" });
    const snapshot = await buildDocsSnapshot({
      manifest: { entries: [{ group: "operations", slug: "a", path: "docs/a.md", summary: "요약" }] },
      projectRoot: root,
    });

    assert.match(snapshot.docs[0].html, /&lt;프로젝트 루트&gt;|<프로젝트 루트>/u);
    assert.doesNotMatch(snapshot.docs[0].html, /\/home\/ubuntu/u);
  });

  it("비밀값이 있으면 빌드를 실패시킨다", async () => {
    const root = await fixture({ "docs/a.md": "# 제목\n\nDATABASE_URL=postgres://u:p@h/db\n" });

    await assert.rejects(
      buildDocsSnapshot({
        manifest: { entries: [{ group: "operations", slug: "a", path: "docs/a.md", summary: "요약" }] },
        projectRoot: root,
      }),
      /공개할 수 없는 값/u,
    );
  });

  it("매니페스트가 가리키는 파일이 없으면 실패시킨다", async () => {
    const root = await fixture({ "docs/a.md": "# 제목\n\n본문\n" });

    await assert.rejects(
      buildDocsSnapshot({
        manifest: { entries: [{ group: "operations", slug: "b", path: "docs/missing.md", summary: "요약" }] },
        projectRoot: root,
      }),
      /찾을 수 없습니다/u,
    );
  });

  it("최상위 제목이 없는 문서를 거부한다", async () => {
    const root = await fixture({ "docs/a.md": "본문만 있다\n" });

    await assert.rejects(
      buildDocsSnapshot({
        manifest: { entries: [{ group: "operations", slug: "a", path: "docs/a.md", summary: "요약" }] },
        projectRoot: root,
      }),
      /최상위 제목/u,
    );
  });

  it("매니페스트 순서를 그대로 지킨다", async () => {
    const root = await fixture({
      "docs/a.md": "# 가\n\n본문\n",
      "docs/b.md": "# 나\n\n본문\n",
    });
    const snapshot = await buildDocsSnapshot({
      manifest: {
        entries: [
          { group: "operations", slug: "b", path: "docs/b.md", summary: "요약" },
          { group: "operations", slug: "a", path: "docs/a.md", summary: "요약" },
        ],
      },
      projectRoot: root,
    });

    assert.deepEqual(
      snapshot.docs.map((doc) => doc.slug),
      ["b", "a"],
    );
  });
});
  • [ ] Step 2: RED를 확인한다

Run: node --import tsx --test tests/unit/docs-snapshot.test.ts Expected: FAIL — Cannot find module '../../lib/docs-snapshot'

  • [ ] Step 3: 생성기를 쓴다

Create lib/docs-snapshot.ts:

import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";

import { assertDocPublicSafe, redactDocText } from "./docs-safety";
import { extractDocTitle, renderDocMarkdown } from "./docs-markdown";
import {
  parseDocsPublicSnapshot,
  type DocPublic,
  type DocsManifest,
  type DocsPublicSnapshot,
} from "./docs-schema";

interface BuildOptions {
  manifest: DocsManifest;
  projectRoot: string;
}

export async function buildDocsSnapshot({ manifest, projectRoot }: BuildOptions): Promise<DocsPublicSnapshot> {
  const docs: DocPublic[] = [];

  for (const entry of manifest.entries) {
    const absolutePath = path.join(projectRoot, entry.path);
    const docId = `${entry.group}/${entry.slug}`;

    let raw: string;
    try {
      raw = await readFile(absolutePath, "utf8");
    } catch {
      throw new Error(`${docId}: 원본 문서 ${entry.path}을(를) 찾을 수 없습니다.`);
    }

    const { text } = redactDocText(raw);
    assertDocPublicSafe(text, docId);

    const title = extractDocTitle(text);
    const { html, toc } = renderDocMarkdown(text);

    docs.push({
      group: entry.group,
      slug: entry.slug,
      title,
      summary: entry.summary,
      sourcePath: entry.path,
      html,
      toc,
    });
  }

  return parseDocsPublicSnapshot({ generatedFrom: "content/docs-manifest.ts", docs });
}

export async function writeDocsSnapshot(
  options: BuildOptions & { outputPath: string },
): Promise<DocsPublicSnapshot> {
  const snapshot = await buildDocsSnapshot(options);
  await writeFile(options.outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
  return snapshot;
}
  • [ ] Step 4: GREEN을 확인한다

Run: node --import tsx --test tests/unit/docs-snapshot.test.ts Expected: PASS — 6 tests

  • [ ] Step 5: 빌드 스크립트를 쓴다

Create scripts/verify-docs.ts:

import path from "node:path";
import { fileURLToPath } from "node:url";

import { docsManifest } from "../content/docs-manifest";
import { parseDocsManifest } from "../lib/docs-schema";
import { writeDocsSnapshot } from "../lib/docs-snapshot";

const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const outputPath = path.join(projectRoot, "content", "generated", "docs.public.json");

async function main(): Promise<void> {
  const manifest = parseDocsManifest(docsManifest);
  const snapshot = await writeDocsSnapshot({ manifest, projectRoot, outputPath });

  console.log(`Verified ${snapshot.docs.length} public documents.`);
}

main().catch((error: unknown) => {
  console.error(error instanceof Error ? error.message : error);
  process.exitCode = 1;
});
  • [ ] Step 6: npm script를 빌드 앞단에 연결한다

package.jsonscripts에서 verify:docs를 더하고 build에 끼운다.

"build": "npm run verify:evidence && npm run verify:content && npm run verify:docs && npm run verify:manual && next build && npm run verify:bundle",
"verify:docs": "node --import tsx scripts/verify-docs.ts",
  • [ ] Step 7: 실제 34편으로 돌려 본다

Run: npm run verify:docs Expected: PASS — Verified 34 public documents.

실패하면 메시지가 가리키는 문서를 고친다. 비밀값 실패는 원본을 고치고, 가림 대상은 정규식을 넓히지 말고 왜 안 걸렸는지 먼저 확인한다.

  • [ ] Step 8: 커밋
git add lib/docs-snapshot.ts scripts/verify-docs.ts tests/unit/docs-snapshot.test.ts package.json content/generated/docs.public.json
git commit -m "feat(harness): 공개 문서 스냅샷을 빌드 때 생성한다"

Task 6: /docs 목록 화면

Files:

  • Create: app/docs/page.tsx
  • Create: app/docs/docs.module.css
  • Create: lib/docs.ts
  • Modify: content/site.ts (siteRoutes에 문서 추가)
  • Modify: scripts/verify-content.ts:27 (REQUIRED_ROUTES)
  • Test: tests/integration/docs-page.test.tsx

Interfaces:

  • Consumes: content/generated/docs.public.json, DOC_GROUPS, parseDocsPublicSnapshot

  • Produces:

    • function getDocGroups(): { id: DocGroupId; label: string; description: string; docs: DocPublic[] }[]
    • function getDoc(group: string, slug: string): DocPublic | undefined
    • function getAllDocs(): DocPublic[]
    • siteRoutes{ id: "docs", href: "/docs", label: "문서", shortLabel: "문서" }
  • [ ] Step 1: 실패하는 테스트를 쓴다

Create tests/integration/docs-page.test.tsx:

import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { before, describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";

const require = createRequire(import.meta.url);
require.extensions[".css"] = () => undefined;

let DocsPage: typeof import("../../app/docs/page").default;
let getAllDocs: typeof import("../../lib/docs").getAllDocs;
let getDocGroups: typeof import("../../lib/docs").getDocGroups;
let siteRoutes: typeof import("../../content/site").siteRoutes;

before(async () => {
  ({ default: DocsPage } = await import("../../app/docs/page"));
  ({ getAllDocs, getDocGroups } = await import("../../lib/docs"));
  ({ siteRoutes } = await import("../../content/site"));
});

describe("문서 목록", () => {
  it("헤더 탐색에 문서를 다섯 번째로 넣는다", () => {
    assert.deepEqual(
      siteRoutes.map((route) => route.href),
      ["/", "/guide", "/5240lab", "/roadmap", "/docs"],
    );
  });

  it("네 묶음을 선언 순서대로 돌려준다", () => {
    assert.deepEqual(
      getDocGroups().map((group) => group.id),
      ["design", "specs", "operations", "standards"],
    );
  });

  it("모든 묶음에 문서가 하나 이상 있다", () => {
    for (const group of getDocGroups()) {
      assert.ok(group.docs.length > 0, `${group.id} 묶음이 비어 있습니다.`);
    }
  });

  it("문서 34편을 담는다", () => {
    assert.equal(getAllDocs().length, 34);
  });

  it("목록에 제목, 요약, 원본 경로와 상세 링크를 담는다", () => {
    const html = renderToStaticMarkup(<DocsPage />);

    assert.match(html, /공개 문서 조회 설계/u);
    assert.match(html, /href="\/docs\/design\/public-docs-browser"/u);
    assert.match(html, /docs\/superpowers\/specs\/2026-08-17-public-docs-browser-design\.md/u);
  });

  it("제목이 하나뿐이다", () => {
    const html = renderToStaticMarkup(<DocsPage />);

    assert.equal(html.match(/<h1/gu)?.length, 1);
  });
});
  • [ ] Step 2: RED를 확인한다

Run: npm run test:integration Expected: FAIL — Cannot find module '../../app/docs/page'

  • [ ] Step 3: 런타임 접근자를 쓴다

Create lib/docs.ts:

import snapshotJson from "../content/generated/docs.public.json";
import { DOC_GROUPS, parseDocsPublicSnapshot, type DocGroupId, type DocPublic } from "./docs-schema";

const snapshot = parseDocsPublicSnapshot(snapshotJson);

export interface DocGroupView {
  id: DocGroupId;
  label: string;
  description: string;
  docs: DocPublic[];
}

export function getAllDocs(): DocPublic[] {
  return snapshot.docs;
}

export function getDocGroups(): DocGroupView[] {
  return DOC_GROUPS.map((group) => ({
    id: group.id,
    label: group.label,
    description: group.description,
    docs: snapshot.docs.filter((doc) => doc.group === group.id),
  }));
}

export function getDoc(group: string, slug: string): DocPublic | undefined {
  return snapshot.docs.find((doc) => doc.group === group && doc.slug === slug);
}
  • [ ] Step 4: 경로를 등록한다

content/site.tssiteRoutes 마지막에 더한다.

  { id: "docs", href: "/docs", label: "문서", shortLabel: "문서" },

scripts/verify-content.tsREQUIRED_ROUTES를 바꾼다.

const REQUIRED_ROUTES = ["/", "/guide", "/5240lab", "/roadmap", "/docs"] as const;
  • [ ] Step 5: 목록 화면을 쓴다

Create app/docs/page.tsx:

import type { Metadata } from "next";
import Link from "next/link";

import { getDocGroups } from "../../lib/docs";
import "./docs.module.css";

export const metadata: Metadata = {
  title: "문서 — 5240lab Harness",
  description: "하네스 작업이 만들어낸 설계, 명세, 운영 문서를 공개합니다.",
};

export default function DocsPage() {
  const groups = getDocGroups();

  return (
    <div className="docs-page">
      <section className="docs-hero" aria-labelledby="docs-title">
        <p className="technical-label">Documents / Public read-only</p>
        <h1 id="docs-title">하네스가 남긴 문서</h1>
        <p className="docs-hero__lead">
          설계에서 명세, 작업목록, 검증 기록까지 실제로 쓴 문서를 그대로 공개합니다.
          매니페스트에 선언한 문서만 나옵니다.
        </p>
      </section>

      {groups.map((group) => (
        <section className="docs-group" key={group.id} aria-labelledby={`group-${group.id}`}>
          <div className="docs-group__heading">
            <p className="technical-label">{group.docs.length}편</p>
            <h2 id={`group-${group.id}`}>{group.label}</h2>
            <p>{group.description}</p>
          </div>
          <ul className="docs-list">
            {group.docs.map((doc) => (
              <li key={`${doc.group}/${doc.slug}`}>
                <Link className="docs-card" href={`/docs/${doc.group}/${doc.slug}`}>
                  <span className="docs-card__title">{doc.title}</span>
                  <span className="docs-card__summary">{doc.summary}</span>
                  <code className="docs-card__path">{doc.sourcePath}</code>
                </Link>
              </li>
            ))}
          </ul>
        </section>
      ))}
    </div>
  );
}
  • [ ] Step 6: 스타일을 쓴다

Create app/docs/docs.module.css:

:global(.docs-page) {
  display: flex;
  min-width: 0;
  flex-direction: column;
  gap: var(--space-16);
}

:global(.docs-hero) {
  padding-block: var(--space-8) var(--space-12);
  border-bottom: 1px solid var(--border);
}

/* ch는 라틴 "0" 폭 기준이라 한글 제목 줄바꿈을 못 지킨다. 글자 크기에 비례하는 em으로 잡는다. */
:global(.docs-hero h1) {
  max-width: 11em;
  margin-block: var(--space-4) var(--space-6);
}

:global(.docs-hero__lead) {
  max-width: 43rem;
  color: var(--foreground-muted);
  font-size: clamp(1.125rem, 1rem + 0.5vw, 1.5rem);
}

:global(.docs-group__heading h2) {
  margin-block: var(--space-2) var(--space-3);
}

:global(.docs-group__heading p:last-child) {
  color: var(--foreground-muted);
}

:global(.docs-list) {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
  gap: var(--space-4);
  margin: var(--space-6) 0 0;
  padding: 0;
  list-style: none;
}

:global(.docs-card) {
  display: grid;
  gap: var(--space-2);
  min-width: 0;
  height: 100%;
  padding: var(--space-6);
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  background: var(--surface);
  color: inherit;
  text-decoration: none;
}

:global(.docs-card:hover),
:global(.docs-card:focus-visible) {
  border-color: var(--primary);
}

:global(.docs-card__title) {
  font-weight: 800;
}

:global(.docs-card__summary) {
  color: var(--foreground-muted);
  font-size: 0.9rem;
}

:global(.docs-card__path) {
  color: var(--foreground-muted);
  font-size: 0.75rem;
  overflow-wrap: anywhere;
}
  • [ ] Step 7: GREEN을 확인한다

Run: npm run test:integration Expected: PASS — 문서 목록 6 tests 포함 전체 통과

  • [ ] Step 8: 커밋
git add app/docs lib/docs.ts content/site.ts scripts/verify-content.ts tests/integration/docs-page.test.tsx
git commit -m "feat(harness): 문서 목록 화면과 다섯 번째 경로를 더한다"

Task 7: 문서 상세 화면

Files:

  • Create: app/docs/[group]/[slug]/page.tsx
  • Modify: app/docs/docs.module.css (본문 스타일 추가)
  • Modify: app/sitemap.ts
  • Test: tests/integration/docs-detail.test.tsx

Interfaces:

  • Consumes: getDoc, getAllDocs (Task 6), DOC_GROUPS (Task 4)

  • Produces: /docs/<group>/<slug> 정적 경로 34개, sitemap 항목 34개 추가

  • [ ] Step 1: 실패하는 테스트를 쓴다

Create tests/integration/docs-detail.test.tsx:

import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { before, describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";

const require = createRequire(import.meta.url);
require.extensions[".css"] = () => undefined;

let DocPage: typeof import("../../app/docs/[group]/[slug]/page").default;
let generateStaticParams: typeof import("../../app/docs/[group]/[slug]/page").generateStaticParams;
let sitemap: typeof import("../../app/sitemap").default;

before(async () => {
  ({ default: DocPage, generateStaticParams } = await import("../../app/docs/[group]/[slug]/page"));
  ({ default: sitemap } = await import("../../app/sitemap"));
});

describe("문서 상세", () => {
  it("문서 34편을 모두 정적 경로로 만든다", () => {
    assert.equal(generateStaticParams().length, 34);
  });

  it("본문, 원본 경로, 목록 링크를 담는다", async () => {
    const html = renderToStaticMarkup(
      await DocPage({ params: Promise.resolve({ group: "design", slug: "public-docs-browser" }) }),
    );

    assert.match(html, /공개 문서 조회 설계/u);
    assert.match(html, /href="\/docs"/u);
    assert.match(html, /docs\/superpowers\/specs\/2026-08-17-public-docs-browser-design\.md/u);
  });

  it("문서 제목을 h2부터 쓰고 페이지 h1은 하나만 둔다", async () => {
    const html = renderToStaticMarkup(
      await DocPage({ params: Promise.resolve({ group: "design", slug: "public-docs-browser" }) }),
    );

    assert.equal(html.match(/<h1/gu)?.length, 1);
    assert.match(html, /<h2 id="/u);
  });

  it("목차를 본문 앵커와 같은 ID로 만든다", async () => {
    const html = renderToStaticMarkup(
      await DocPage({ params: Promise.resolve({ group: "design", slug: "public-docs-browser" }) }),
    );

    assert.match(html, /href="#배경"/u);
    assert.match(html, /id="배경"/u);
  });

  it("sitemap에 문서 경로를 담는다", () => {
    const urls = sitemap().map((entry) => entry.url);

    assert.ok(urls.includes("https://harness.insapien.co.kr/docs"));
    assert.ok(urls.includes("https://harness.insapien.co.kr/docs/design/public-docs-browser"));
  });
});
  • [ ] Step 2: RED를 확인한다

Run: npm run test:integration Expected: FAIL — Cannot find module '../../app/docs/[group]/[slug]/page'

  • [ ] Step 3: 상세 화면을 쓴다

Create app/docs/[group]/[slug]/page.tsx:

import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";

import { getAllDocs, getDoc } from "../../../../lib/docs";
import "../../docs.module.css";

interface PageProps {
  params: Promise<{ group: string; slug: string }>;
}

export function generateStaticParams() {
  return getAllDocs().map((doc) => ({ group: doc.group, slug: doc.slug }));
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { group, slug } = await params;
  const doc = getDoc(group, slug);
  if (!doc) return {};

  return { title: `${doc.title} — 5240lab Harness`, description: doc.summary };
}

export default async function DocPage({ params }: PageProps) {
  const { group, slug } = await params;
  const doc = getDoc(group, slug);
  if (!doc) notFound();

  return (
    <div className="doc-page">
      <section className="doc-header">
        <p className="technical-label">
          <Link href="/docs">문서</Link> / {doc.sourcePath}
        </p>
        <h1>{doc.title}</h1>
        <p className="doc-header__summary">{doc.summary}</p>
      </section>

      <div className="doc-body">
        {doc.toc.length > 0 && (
          <nav className="doc-toc" aria-label="문서 목차">
            <p className="technical-label">목차</p>
            <ul>
              {doc.toc.map((heading) => (
                <li key={heading.id} data-level={heading.level}>
                  <a href={`#${heading.id}`}>{heading.text}</a>
                </li>
              ))}
            </ul>
          </nav>
        )}
        <article className="doc-article" dangerouslySetInnerHTML={{ __html: doc.html }} />
      </div>
    </div>
  );
}

dangerouslySetInnerHTML을 쓰는 이유는 본문이 빌드 때 markdown-it이 html: false로 만든 HTML이고, 원본 문서의 원시 HTML은 이스케이프되어 실행 가능한 태그가 남지 않기 때문이다. 이 전제가 깨지면 Task 3의 원시 HTML 테스트가 먼저 실패한다.

  • [ ] Step 4: 본문 스타일을 더한다

app/docs/docs.module.css 끝에 추가한다.

:global(.doc-page) {
  display: flex;
  min-width: 0;
  flex-direction: column;
  gap: var(--space-12);
}

:global(.doc-header) {
  padding-block: var(--space-8) var(--space-8);
  border-bottom: 1px solid var(--border);
}

/* ch는 라틴 "0" 폭 기준이라 한글 제목 줄바꿈을 못 지킨다. 글자 크기에 비례하는 em으로 잡는다. */
:global(.doc-header h1) {
  max-width: 16em;
  margin-block: var(--space-4) var(--space-4);
}

:global(.doc-header__summary) {
  max-width: 43rem;
  color: var(--foreground-muted);
}

:global(.doc-body) {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-8);
}

@media (min-width: 64rem) {
  :global(.doc-body) {
    grid-template-columns: 16rem minmax(0, 1fr);
    align-items: start;
  }

  :global(.doc-toc) {
    position: sticky;
    top: var(--space-8);
  }
}

:global(.doc-toc ul) {
  display: grid;
  gap: var(--space-1);
  margin: var(--space-3) 0 0;
  padding: 0;
  list-style: none;
}

:global(.doc-toc a) {
  color: var(--foreground-muted);
  font-size: 0.85rem;
  text-decoration: none;
}

:global(.doc-toc a:hover),
:global(.doc-toc a:focus-visible) {
  color: var(--primary);
}

:global(.doc-toc li[data-level="3"]) { padding-left: var(--space-3); }
:global(.doc-toc li[data-level="4"]) { padding-left: var(--space-6); }

:global(.doc-article) {
  min-width: 0;
  max-width: var(--reading-max);
}

:global(.doc-article table) {
  display: block;
  width: max-content;
  max-width: 100%;
  overflow-x: auto;
  border-collapse: collapse;
}

:global(.doc-article th),
:global(.doc-article td) {
  padding: var(--space-2) var(--space-4);
  border: 1px solid var(--border);
  text-align: left;
}

:global(.doc-article pre) {
  max-width: 100%;
  padding: var(--space-4);
  border-radius: var(--space-2);
  background: #0b1020;
  color: #eef2fa;
  font-size: 0.8rem;
  line-height: 1.7;
  overflow-x: auto;
}

:global(.doc-article code) {
  overflow-wrap: anywhere;
}
  • [ ] Step 5: sitemap에 문서 경로를 더한다

app/sitemap.ts를 바꾼다.

import type { MetadataRoute } from "next";

import releaseJson from "../content/release.json";
import { site, siteRoutes } from "../content/site";
import { getAllDocs } from "../lib/docs";

export default function sitemap(): MetadataRoute.Sitemap {
  const lastModified = new Date(releaseJson.createdOn);
  const routes = siteRoutes.map(({ href }) => ({
    url: new URL(href, site.url).toString(),
    lastModified,
    changeFrequency: "monthly" as const,
    priority: href === "/" ? 1 : 0.8,
  }));
  const docs = getAllDocs().map((doc) => ({
    url: new URL(`/docs/${doc.group}/${doc.slug}`, site.url).toString(),
    lastModified,
    changeFrequency: "monthly" as const,
    priority: 0.5,
  }));

  return [...routes, ...docs];
}
  • [ ] Step 6: GREEN을 확인한다

Run: npm run test:integration Expected: PASS — 문서 상세 5 tests 포함 전체 통과

  • [ ] Step 7: 커밋
git add "app/docs/[group]" app/docs/docs.module.css app/sitemap.ts tests/integration/docs-detail.test.tsx
git commit -m "feat(harness): 문서 상세 화면과 sitemap 문서 경로를 더한다"

Task 8: 브라우저 검증과 배포 스모크

Files:

  • Create: tests/browser/docs.spec.ts
  • Modify: tests/browser/responsive.spec.ts:3 (publicRoutes)
  • Modify: tests/browser/heading-lines.spec.ts:12-17 (expectedLines)
  • Modify: tests/browser/heading-scale.spec.ts:7 (routes)
  • Modify: scripts/smoke.mjs:22-24 (pageChecks)
  • Modify: docs/operations.md (외부 스모크 경로 목록)

Interfaces:

  • Consumes: Task 6·7이 만든 /docs/docs/<group>/<slug>

  • Produces: 없음 (검증만)

  • [ ] Step 1: 실패하는 브라우저 테스트를 쓴다

Create tests/browser/docs.spec.ts:

import { expect, test } from "@playwright/test";

test("문서 목록이 네 묶음과 문서 링크를 보여준다", async ({ page }) => {
  await page.setViewportSize({ width: 1440, height: 900 });
  await page.goto("/docs");

  await expect(page.getByRole("heading", { level: 1 })).toHaveText("하네스가 남긴 문서");
  for (const label of ["설계", "명세·계획", "운영·검증", "기준문서"]) {
    await expect(page.getByRole("heading", { level: 2, name: label })).toBeVisible();
  }
  await expect(page.getByRole("link", { name: /공개 문서 조회 설계/u })).toBeVisible();
});

test("목록에서 문서를 열면 본문과 목차가 보인다", async ({ page }) => {
  await page.setViewportSize({ width: 1440, height: 900 });
  await page.goto("/docs");
  await page.getByRole("link", { name: /공개 문서 조회 설계/u }).click();

  await expect(page).toHaveURL(/\/docs\/design\/public-docs-browser$/u);
  await expect(page.getByRole("navigation", { name: "문서 목차" })).toBeVisible();
  await expect(page.getByRole("heading", { level: 2, name: "배경" })).toBeVisible();
});

test("문서 본문에 실행 가능한 스크립트가 없다", async ({ page }) => {
  await page.goto("/docs/design/public-docs-browser");

  const scripts = await page.locator("article.doc-article script").count();
  expect(scripts).toBe(0);
});

for (const width of [375, 768, 1024, 1440, 1920]) {
  test(`문서 본문이 ${width}px에서 가로로 넘치지 않는다`, async ({ page }) => {
    await page.setViewportSize({ width, height: 900 });
    await page.goto("/docs/specs/001-tasks");

    const overflow = await page.evaluate(
      () => document.documentElement.scrollWidth - document.documentElement.clientWidth,
    );
    expect(overflow).toBeLessThanOrEqual(1);
  });
}
  • [ ] Step 2: 기존 브라우저 테스트에 /docs를 더한다

tests/browser/responsive.spec.ts:

const publicRoutes = ["/", "/guide", "/5240lab", "/roadmap", "/docs"] as const;

tests/browser/heading-lines.spec.tsexpectedLines 끝에 더한다.

  { route: "/docs", lines: 1 },

tests/browser/heading-scale.spec.ts:

const routes = ["/", "/guide", "/5240lab", "/roadmap", "/docs"] as const;
  • [ ] Step 3: RED를 확인한다

Run: npx playwright test docs heading-lines heading-scale responsive --reporter=line Expected: FAIL — /docs가 404이거나 목록 제목이 없다는 오류. Task 6·7이 이미 끝났다면 이 단계는 바로 PASS일 수 있다. 그 경우 통과를 기록하고 다음으로 간다.

  • [ ] Step 4: 배포 스모크에 /docs를 더한다

scripts/smoke.mjspageChecks를 바꾼다.

  ["/guide", "작업 가이드"],
  ["/5240lab", "5240lab"],
  ["/roadmap", "로드맵"],
  ["/docs", "하네스가 남긴 문서"],
  ["/docs/design/public-docs-browser", "공개 문서 조회 설계"],
  • [ ] Step 5: 운영 매뉴얼의 스모크 경로 목록을 고친다

docs/operations.md에서 아래 문장을 찾는다.

외부 smoke는 `/`, `/guide`, `/5240lab`, `/roadmap`, `/api/health`,
`/robots.txt`, `/sitemap.xml`, 정적 자산과 임의 404의 status·복구 링크를 검사한다.

다음으로 바꾼다.

외부 smoke는 `/`, `/guide`, `/5240lab`, `/roadmap`, `/docs`, 문서 상세 한 편,
`/api/health`, `/robots.txt`, `/sitemap.xml`, 정적 자산과 임의 404의 status·복구
링크를 검사한다.
  • [ ] Step 6: 전체 검증을 돌린다

Run: npm run verify:app Expected: PASS

Run: npx playwright test --reporter=line Expected: PASS — 기존 77건에 문서 8건이 더해져 전부 통과

  • [ ] Step 7: 커밋
git add tests/browser scripts/smoke.mjs docs/operations.md
git commit -m "feat(harness): 문서 경로를 브라우저 검증과 배포 스모크에 넣는다"

완료 조건

  • npm run verify:appnpx playwright test가 모두 통과한다.
  • npm run verify:docsVerified 34 public documents.를 낸다.
  • dependenciesnext, react, react-dom 셋 그대로다.
  • /docs와 문서 34편의 상세 경로가 JavaScript 없이 읽힌다.
  • 배포 후 외부 스모크가 /docs와 문서 상세 한 편을 200으로 확인한다.