Navigation

for humanity

Markdown 문서를 공개와 내부로 나눠 정적 사이트로 만드는 CLI입니다. npm 패키지 for-humanity로 배포하고 이 사이트도 이 도구로 만들었습니다. 2026.09.30 Astro 프로토타입으로 시작해 Hono JSX와 Vite 기반으로 다시 짰고 2026.10.03 첫 배포 뒤 2026.10.23에 0.19.0을 게시했습니다. 설계, 구현, 문서, 테스트, 배포를 혼자 했습니다.

핵심 기여

과제본인 기여확인한 동작
공개 문서와 내부 근거의 공존수집 단계의 참조 검사와 공개 빌드 범위 분리내부 문서 참조는 빌드에서 거부
문서 입력 오류의 위치 추적파일과 줄 번호를 포함한 검증 오류잘못된 frontmatter, 코드 표기와 스니펫을 작성 위치에서 확인
패키지를 처음 쓰는 흐름init과 배포 템플릿, 패키지 검증과 릴리즈 자동화설정과 첫 문서, 호스팅 준비를 하나의 흐름으로 제공

개요

항목내용
기간2026.09 ~ 현재
역할단독 설계, 구현, 문서, 검증과 배포
인원1명
서비스설정 파일 하나와 content 폴더로 dev, build, preview, init을 제공하는 문서 사이트 생성기. 공개 문서만 배포하고 내부 문서는 로컬에서만 읽습니다
링크for-humanity.fyi, GitHub, npm
기술TypeScript, Hono(JSX 렌더링과 라우트), Vite 8, unified(remark, rehype), Shiki, Mermaid 12, Zustand, Zod 4, Citty, Consola, Clack, yaml, jiti
검증과 배포Vitest, Playwright, Biome, Stylelint, TypeScript, knip, publint. GitHub Actions CI는 Ubuntu Node 22.12.0과 24, Windows Node 22.12.0. 버전 태그로 npm Trusted Publishing과 GitHub Release. 문서 사이트는 Cloudflare Workers 정적 자산 배포 설정
요구Node.js 22.12.0 이상, MIT

세 단계로 진행했습니다.

단계시기내용
프로토타입과 첫 배포2026.09.30 ~ 2026.10.03Astro 프로토타입, Hono와 React spike, 0.1.0 배포. dev, build, preview, 그룹 탐색, 목차, 코드 강조, note, details, 상태 표지
문서 기능2026.10.04 ~ 2026.10.13중첩 그룹과 부모 문서, 지문 자산 수집, Hono JSX 전환과 React 제거, sitemap, 글꼴 번들, 코드 표기와 스니펫, 상태 저장 탐색
엔진과 구조2026.10.16 ~ 2026.10.22Vite 8로 dev와 preview 통합, config.ts, Mermaid 12, 공개와 내부 분리, 엄격한 입력 검증, 메타 태그, basePath, init 명령

맡은 범위

문제와 해결

코드는 공개 저장소의 실제 코드입니다. 주석과 일부 줄만 줄였습니다.

프로토타입의 엔진을 통째로 바꾸기

문제. 첫날 Astro로 프로토타입을 만들었지만 문서 폴더가 저장소 밖에 있을 때 빌드가 어긋났고 브라우저 동작을 Astro 컴포넌트와 별도 스크립트에 나눠 두게 됐습니다. 다음 날 Hono와 React로 spike를 돌렸고 0.5.0에서는 React 서버 렌더러와 그 의존성을 제거했습니다.

판단. 페이지는 Hono JSX로 서버에서 그리고 브라우저에는 꼭 필요한 상호작용만 둔다는 기준을 세웠습니다. 개발 서버와 정적 빌드는 같은 문서 준비 단계와 라우트를 공유해야 합니다.

구현. 2026.10.01에 Astro를 걷어내고 Hono와 React로 바꿨습니다. 0.5.0에서 React 서버 렌더러를 제거하고 Hono JSX로 페이지를 생성하며 탐색을 Hono 클라이언트 컴포넌트와 Zustand로 옮겼습니다. 0.13.0에서 custom 파일 감시와 SSE 대신 Vite 8 하나로 dev와 preview를 묶고 CLI를 Citty로 바꿨습니다.

flowchart LR
    A["2026.09.30 Astro 프로토타입"] --> B["2026.10.01 Hono와 React spike"]
    B --> C["0.5.0 Hono JSX, React 제거"]
    C --> D["0.13.0 Vite 8로 dev와 preview 통합, Citty CLI"]

결과. 서버 렌더링과 브라우저 스크립트의 경계가 하나로 정해졌고 이후 기능은 그 경계 위에 올렸습니다. dev에서 보는 문서와 build 결과가 같은 코드로 만들어집니다.

공개 문서와 내부 문서를 나누고 내부 참조를 빌드에서 막기

문제. 한 폴더에 공개 가이드와 내부 메모가 섞여 있으면 배포 때 내부 문서가 새거나 공개 문서가 내부 그림을 참조해 링크가 깨집니다. 폴더를 나누는 것만으로는 참조 실수를 막지 못합니다.

판단. 0.14.0에서 설정을 프로젝트 루트로 올리고 content 아래에 public과 private을 두었습니다. dev는 둘 다 보여 주고 build는 홈과 public만 만듭니다. 공개 범위에서 내부 파일을 참조하면 경고가 아니라 실패로 멈춥니다. symlink가 가리키는 실제 경로까지 같은 규칙으로 봅니다.

구현. 문서 수집은 공개 루트와 dev에서만 내부 루트를 읽습니다. 링크와 그림의 경로는 렌더 회차의 범위로 검사하고 private 세그먼트로 시작하는 절대 URL도 막습니다.

assert-content-path.tsts
export const assertContentPath = (params: ContentPathParams) => {
    for (const path of [resolve(params.path), ...(existsSync(params.path) ? [realpathSync(params.path)] : [])]) {
        const location = relative(params.scope.root, path);

        if (isOutsideRoot(location) || hasReservedFolder(location)) {
            throw new Error(`${copy_error_reference_outside}: ${params.path}`);
        }

        if (!params.scope.includePrivate && !isOutsideRoot(relative(params.scope.privateRoot, path))) {
            throw new Error(`${copy_error_private_reference}: ${params.path}`);
        }
    }
};
_rehype-content-links.tsts
if (path.startsWith("/")) {
    if (!options.scope.includePrivate && new URL(path, "https://content.invalid").pathname.toLowerCase().split("/")[1] === content_private_segment) {
        file.fail(`${copy_error_private_url}: ${url}`, node);
    }
    continue;
}

const target = resolve(dirname(file.path), path);
assertContentPath({scope: options.scope, path: target});
flowchart LR
    Root["content/"] --> Home["README.md 홈"]
    Root --> Pub["public/"]
    Root --> Priv["private/"]
    Home --> Dev["dev: 전부 표시"]
    Pub --> Dev
    Priv --> Dev
    Home --> Build["build: content/dist"]
    Pub --> Build
    Priv -.->|참조하면 빌드 실패| Build

결과. 내부 문서는 로컬에서만 보이고 공개 빌드에는 페이지, 사이트맵, 탐색 데이터, 자원 어디에도 남지 않습니다. 이 사이트의 사실 원본과 채용 문서가 그 규칙으로 보호됩니다.

참조한 자원만 내용 지문 URL로 복사하기

문제. 그림과 첨부를 폴더째 복사하면 쓰지 않는 파일까지 배포되고 같은 이름의 파일을 바꿔도 CDN과 브라우저가 옛 캐시를 내줍니다.

판단. 문서가 실제로 참조한 파일만 수집하고 파일 내용의 sha256을 이름에 붙입니다. 같은 내용은 같은 URL이고 바뀐 내용은 새 URL이라 1년 immutable 캐시를 걸 수 있습니다.

구현. Markdown 이미지와 참조형 이미지, 원시 HTML의 src와 srcset과 poster, video와 audio와 track과 embed까지 같은 수집기를 지납니다. 0.2.1에서 CSS와 스크립트 URL에 먼저 적용했고 0.4.0에서 문서 자원으로 넓혔습니다.

to-asset-path.tsts
export const toAssetPath = (params: AssetPathParams) => {
    const extension = posix.extname(params.path);

    return `${posix.dirname(params.path)}/${posix.basename(params.path, extension)}.${createHash("sha256").update(readFileSync(params.file)).digest("hex")}${extension}`;
};

결과. 배포 결과에는 참조한 파일만 들어가고 _fh/ 아래 자원은 immutable 캐시로 내보냅니다. 자원 내용이 달라지면 URL도 달라져 이전 내용의 캐시와 구분됩니다.

잘못된 입력을 파일과 줄 번호로 멈추기

문제. 제목 없는 part, 닫히지 않은 스니펫 region, 이미지 자리에 들어간 PDF, 없는 영상 파일을 이전 버전은 그대로 받아들였습니다. 빈 구분선이나 깨진 그림이 배포된 뒤에야 보였습니다.

판단. 0.15.0에서 이런 입력을 모두 빌드 실패로 바꾸고 파일과 줄 번호를 함께 출력하기로 했습니다. 없는 문서 링크처럼 배포는 가능한 경우만 경고로 남깁니다. 변경 이력에 Breaking change로 적고 이전 안내 표를 두었습니다.

구현. part 지시문은 일반 텍스트 제목과 다음 ## 절이 있어야 통과합니다. 스니펫은 열린 region이 파일 끝까지 닫히지 않으면 실패합니다.

remark-parts.tsts
const title = toTitleText(node.children, source);

if (title === undefined) {
    file.fail(`${copy_error_directive_syntax}: ${node.name} ${copy_error_part_plain_title}`, node);
}

if (title === "") {
    file.fail(`${copy_error_directive_syntax}: ${node.name} ${copy_error_part_empty_title}`, node);
}

const section = tree.children
    .slice(index + 1)
    .find((next) => (next.type === "heading" && next.depth === section_heading_depth.section) || (next.type === "leafDirective" && next.name === "part"));

if (section?.type !== "heading") {
    file.fail(`${copy_error_directive_syntax}: ${node.name} ${copy_error_part_section}`, node);
}
_to-snippet-body.tsts
if (!found) {
    throw new Error(copy_error_region_missing);
}

if (params.region !== undefined && open.includes(params.region)) {
    throw new Error(copy_error_region_open);
}

결과. 오류가 터미널에 Invalid part syntax, region not closed, unsupported image format처럼 파일과 줄과 함께 나옵니다. 문서를 고치는 사람이 배포 결과를 열어 보지 않아도 됩니다.

그룹과 부모 문서로 탐색을 구성하기

문제. 문서가 늘면 평면 목록으로는 읽는 순서가 보이지 않습니다. 그룹은 클릭할 수 없는 분류이고 부모 문서는 본문이 있는 링크라 둘을 섞으면 탐색이 어긋납니다.

판단. 0.2.0에서 group을 이름 배열로 받아 중첩 그룹을 만들고 0.3.0에서 parent로 문서 아래에 문서를 두었습니다. parent는 사이트 id가 기본이고 ./나 ../로 시작하면 파일 위치 기준으로 한 번만 해소합니다. 없는 부모, 자기 참조, 순환, 그룹 경로 불일치는 오류입니다.

구현. 수집 단계에서 상대 부모를 id로 바꾸고 사이드바는 검증된 부모 관계로 가지를 재귀 구성합니다. 형제는 order, 이름, id 순입니다.

read-docs.tsts
const data = {
    ...parsed.data,
    parent:
        parsed.data.parent !== undefined && /^\.\.?\//.test(parsed.data.parent)
            ? toContentId({scope: params.scope, path: resolve(dirname(file.path), parsed.data.parent)})
            : parsed.data.parent,
};
_to-navigation-docs.tsts
export const toNavigationDocs = (params: NavigationDocsParams): NavigationDoc[] => {
    return params.docs
        .toSorted((a, b) => a.data.order - b.data.order || a.data.name.localeCompare(b.data.name, locale_doc_name) || a.id.localeCompare(b.id, locale_doc_name))
        .map((doc) => ({
            id: doc.id,
            name: doc.data.name,
            label: doc.data.label,
            active: doc.id === params.current,
            children: toNavigationDocs({...params, docs: params.children.get(doc.id) ?? navigation_children_absent}),
        }));
};

결과. 그룹과 부모를 바꿔도 문서 URL과 상대 링크는 그대로입니다. 문서 루트를 옮겨도 상대 부모 관계가 유지됩니다.

본문의 문구를 상태 표지로 바꾸기

문제. 확인 필요나 확인됨 2026-10-01 같은 문구는 문서의 신뢰도를 나타내는데 본문에 묻혀 있으면 눈에 띄지 않습니다. 제목이나 링크 안의 문구까지 바꾸면 앵커와 링크 텍스트가 깨집니다.

판단. 설정의 phrase, kind, date로 문구를 받아 본문에서만 표지로 바꾸고 제목과 링크는 제외합니다. 날짜가 붙은 문구는 날짜까지 하나의 표지로 묶습니다. 설정에 없는 날짜 문구는 경고로 알려 오타를 잡습니다.

구현. 긴 문구를 먼저 매칭하도록 정렬한 정규식 하나로 괄호 안팎을 함께 찾고 mdast의 findAndReplace로 html 노드를 끼워 넣습니다.

remark-status.tsts
const statuses = orderBy(options.status, [(item) => item.phrase.length], ["desc"]);
const phrases = statuses.map((item) => `${escapeRegExp(item.phrase)}${item.date ? `(?: ${status_date_pattern})?` : ""}`).join("|");
const pattern = new RegExp(String.raw`\((${phrases})\)|(${phrases})`, "g");
const ignore = ["link", "linkReference", "heading"];

findAndReplace(tree, [pattern, (_match, inParens, bare) => {
    const text = inParens ?? bare;
    const item = statuses.find((status) => text?.startsWith(status.phrase));
    if (text === undefined || item === undefined) return false;
    return {type: "html", value: `<span class="${clsx("wg_article__pill", ...)}">${escapeHtml(text)}</span>`};
}], {ignore});

결과. 이 사이트의 사실 문서에서 확인 필요가 호박색 표지로 보이고 채용 대시보드의 지원 완료 2026-10-30 같은 문구는 날짜까지 초록 표지가 됩니다.

다섯 층 테스트와 운영체제별 브라우저 검사

문제. 문서 생성기는 파일 시스템, Markdown 처리기, CLI 프로세스, HTTP 서버, 실제 브라우저 배치까지 걸쳐 있어 한 종류의 테스트로는 어디가 깨졌는지 알 수 없습니다. jsdom으로 SVG 배치나 시스템 테마를 판정하면 거짓 통과가 납니다.

판단. static, unit, node, integration, e2e 다섯 층을 두고 같은 조건을 두 층에서 보지 않습니다. 함수 하나로 답이 나오면 unit, 실제 파일과 프로세스를 연결해야 하면 node, 위젯을 그려야 알 수 있으면 integration, 빌드한 사이트나 개발 서버를 지나야 하면 e2e입니다. CI는 Ubuntu Node 22.12.0과 24, Windows Node 22.12.0에서 돌립니다.

구현. 폴더 번호는 사용자 흐름을 따르고 파일 접미사가 실행기 프로젝트를 고릅니다. 케이스 코드는 테스트 제목이 원본입니다.

flowchart TB
    S["static: TypeScript, Biome, Stylelint"] --> U["unit: Vitest, 함수 하나의 반환값"]
    U --> N["node: Vitest, 임시 프로젝트의 파일과 CLI와 preview 서버"]
    N --> I["integration: Playwright, 갤러리에서 마운트한 위젯"]
    I --> E["e2e: Playwright, 빌드한 사이트와 dev 서버의 URL"]

Windows에서만 간헐적으로 실패한 검사가 있었습니다. 새로고침 뒤 커서 장식이 보여야 하는 NAV-010이 0.13.0부터 Windows Node 22.12.0에서 숨김으로 판정됐고 0.13.0, 0.13.1, 0.14.1은 이를 알려진 문제로 적은 채 공개했습니다. 30회 반복 진단에서 21회가 같은 순서로 실패했습니다. 원인은 Windows Chromium이 새로고침으로 문서를 떠나기 직전 (hover: hover) and (pointer: fine) 표시 조건을 잠시 끄는 것이었고 조건 변화 처리가 장식과 함께 마지막 좌표까지 지워 pagehide가 좌표를 저장하지 못했습니다. 조건 변화에는 장식만 숨기고 좌표는 유지하도록 고쳤고 같은 조건 변화를 emulateMedia로 모든 운영체제에서 재현하는 NAV-020을 더했습니다. 수정 확인 실행에서 두 검사를 30회씩 반복해 모두 통과했습니다.

wg-cursor-face.tsxtsx
// Windows Chromium은 새로고침 직전 이 조건을 잠시 비활성으로 전환
const handleCursorMediaChange = () => {
    if (!mediaRef.current?.matches) {
        handleCursorHide();
    }
};

addEventListener("pagehide", handlePageHide, {signal: lifetime.signal});
addEventListener("pageshow", handlePageShow, {signal: lifetime.signal});
media.addEventListener("change", handleCursorMediaChange, {signal: lifetime.signal});

0.18.0의 CI에서는 Ubuntu Node 22.12.0만 같은 검사가 첫 프레임의 문서 목록 위치를 0으로 읽으며 실패했습니다. 인라인 번들이 body 안에서 실행돼 첫 페인트를 막지 못했고 느린 러너에서는 파서가 사이드바를 그린 뒤 번들 실행 전에 양보했습니다. 로컬에서는 재현되지 않아 CDP로 CPU를 20배 느리게 하자 3회 중 2회에서 같은 상태가 기록됐습니다. 첫 페인트를 번들 실행 뒤로 미루는 수정을 2026.10.22에 반영해 0.19.0으로 게시했습니다. Chromium은 render blocking 표시를 사용하고 다른 브라우저는 기존 방식으로 동작합니다.

결과. 층마다 무엇을 보는지가 문서로 정해져 있어 실패 위치가 바로 층을 가리킵니다. 운영체제 의존 버그 두 건을 재현 가능한 검사로 바꿨습니다.

버전 태그 하나로 npm과 GitHub Release를 내기

문제. 수동 publish는 lockfile과 다른 의존성, 빌드 산출물 누락, 같은 버전의 다른 내용 재배포 같은 실수를 막지 못합니다. 장기 npm token을 CI에 두는 것도 피하고 싶었습니다.

판단. v* 태그가 package.json의 version과 같고 main의 조상일 때만 배포합니다. npm에는 token 대신 GitHub OIDC로 Trusted Publishing을 쓰고 같은 버전이 이미 있으면 integrity가 같을 때만 GitHub Release로 넘어갑니다.

구현. 테스트, 검사, 빌드, 생성 파일 diff 확인을 지난 뒤 npm pack으로 패키지를 만들고 publint와 설치 smoke 테스트를 거칩니다. 레지스트리에 같은 버전이 있는지 확인한 뒤에만 publish합니다.

sequenceDiagram
    participant T as 태그 v0.18.0
    participant CI as GitHub Actions
    participant N as npm registry
    participant G as GitHub Release
    T->>CI: 태그가 version과 같고 main의 조상인지 확인
    CI->>CI: test, check, build, 생성 파일 diff
    CI->>CI: npm pack, publint, 설치 smoke
    CI->>N: 같은 버전이 있는지 조회
    N-->>CI: 404이면 publish, 있으면 integrity 비교
    CI->>N: OIDC로 publish
    CI->>G: Release 생성

결과. 0.1.2 이후 태그 기반의 패키지 검증과 릴리즈 흐름을 사용했습니다. 0.1.1은 패키지 검증 단계에서 멈춰 npm에 올라가지 않았고 변경 이력에 그렇게 적었습니다.

템플릿 복사 대신 대화형 init 만들기

문제. 설정 파일과 content 폴더를 node_modules의 template에서 복사해 오라는 안내는 처음 쓰는 사람에게 단계가 많고 호스팅 설정까지 이어지지 않았습니다.

판단. 0.18.0에서 init 명령을 두었습니다. 질문은 명시하지 않은 값에만 하고 추천값에서 바로 타이핑할 수 있게 합니다. 기존 스크립트와 의존성은 유지하고 충돌은 파일을 쓰기 전에 멈추며 쓰다 실패하면 이번 실행의 변경만 되돌립니다. TTY가 아니거나 CI면 --yes나 --deploy를 요구합니다.

구현. 프로젝트를 읽어 패키지 매니저와 저장소를 감지하고 계획을 먼저 출력한 뒤 확인을 받습니다. 0.18.0에서는 GitHub Pages와 Cloudflare Pages 배포 파일을 생성했고 0.19.0에서는 Cloudflare Workers 설정과 준비 스크립트를 더했습니다.

run-init.tsts
const args = initInputSchema.parse(input);
const interactive = !args.yes && process.stdin.isTTY === true && process.stdout.isTTY === true && env_ci !== "true" && env_ci !== "1";

if (!args.yes && !interactive && args.deploy === undefined) {
    throw new Error(init_copy.interactive);
}

const project = await readInitProject({root: resolve(args.dir), manager: args.manager});
const options = await toInitOptions({args, project, interactive});
const plan = await toInitPlan({project, options});

printInitPlan(plan);

결과. 새 프로젝트는 명령 하나로 설정, 첫 문서, 실행 스크립트, 배포 파일을 얻습니다. 계획을 보고 취소하면 아무 파일도 만들지 않습니다.

결과와 근거

배운 것