문서 접근성 개선 시스템 Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: docs/plans/ 의 모든 마크다운 파일을 kanban DB에 저장하고, content-orchestration에 공개 링크로 접근 가능한 /documents 웹 인터페이스를 구축한다.
Architecture: 로컬 마이그레이션 스크립트가 docs/plans/**/*.md 를 읽어 kanban DB documents 테이블에 upsert한다. content-orchestration(Next.js 16)에 /documents (목록)와 /documents/[id] (상세)를 추가하고 kanban DB에서 직접 읽어 렌더링한다. 인증 없이 공개 링크로 접근 가능하며, 새 문서 추가 시 npm run sync-docs 명령으로 수동 동기화한다.
Tech Stack: Next.js 16 App Router, @libsql/client (이미 설치됨), react-markdown + remark-gfm (이미 설치됨), @tailwindcss/typography (이미 설치됨), Turso kanban DB
환경 변수 참고
# content-orchestration/.env 에 추가 필요 (Vercel 환경변수도 추가)
KANBAN_DB_URL=libsql://kanban-migkjy.aws-ap-northeast-1.turso.io
KANBAN_DB_TOKEN=eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzE2ODE3MzgsImlkIjoiYTZlYzYxYjAtYjNkNC00YzVkLThhZjYtNTVkZTU4M2RhYTRmIiwicmlkIjoiZTQ3OGNiZGQtNGU1My00NTgzLWE4MzktMjY4YzU1ZTBlNTQ0In0.YIItPetx8vhqqFDfQen-PeXXFhF4g7elt9koDR5xHvGxikvmITAxTYf2QixYCq3PtyJhaR9S5JlWElF9eLoODQ
# 마이그레이션 스크립트 전용 (로컬 실행 시)
DOCS_ROOT=/Users/nbs22/(Claude)/(claude).projects/business-builder/docs
프로젝트 경로
/Users/nbs22/(Claude)/(claude).projects/business-builder/projects/content-orchestration/
Task 1: kanban DB 클라이언트 + documents 테이블 스키마
Files:
- Create:
src/lib/kanban-db.ts - Modify:
.env(env 변수 추가)
Step 1: .env에 kanban DB 환경변수 추가
# content-orchestration/.env 에 아래 2줄 추가
KANBAN_DB_URL=libsql://kanban-migkjy.aws-ap-northeast-1.turso.io
KANBAN_DB_TOKEN=eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzE2ODE3MzgsImlkIjoiYTZlYzYxYjAtYjNkNC00YzVkLThhZjYtNTVkZTU4M2RhYTRmIiwicmlkIjoiZTQ3OGNiZGQtNGU1My00NTgzLWE4MzktMjY4YzU1ZTBlNTQ0In0.YIItPetx8vhqqFDfQen-PeXXFhF4g7elt9koDR5xHvGxikvmITAxTYf2QixYCq3PtyJhaR9S5JlWElF9eLoODQ
Step 2: src/lib/kanban-db.ts 생성
import { createClient } from '@libsql/client/web';
function getKanbanDb() {
return createClient({
url: process.env.KANBAN_DB_URL!,
authToken: process.env.KANBAN_DB_TOKEN!,
});
}
export interface Document {
id: string;
title: string;
content: string;
file_path: string;
category: string | null;
doc_date: string | null;
created_at: number;
updated_at: number;
}
export async function ensureDocumentsSchema() {
const db = getKanbanDb();
await db.execute(`
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
file_path TEXT NOT NULL UNIQUE,
category TEXT,
doc_date TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}
export async function getDocuments(category?: string): Promise<Document[]> {
const db = getKanbanDb();
const sql = category
? 'SELECT id, title, file_path, category, doc_date, created_at, updated_at FROM documents WHERE category = ? ORDER BY doc_date DESC, created_at DESC'
: 'SELECT id, title, file_path, category, doc_date, created_at, updated_at FROM documents ORDER BY doc_date DESC, created_at DESC';
const args = category ? [category] : [];
const result = await db.execute({ sql, args });
return result.rows.map((r) => ({
id: r[0] as string,
title: r[1] as string,
content: '',
file_path: r[2] as string,
category: r[3] as string | null,
doc_date: r[4] as string | null,
created_at: r[5] as number,
updated_at: r[6] as number,
}));
}
export async function getDocument(id: string): Promise<Document | null> {
const db = getKanbanDb();
const result = await db.execute({
sql: 'SELECT id, title, content, file_path, category, doc_date, created_at, updated_at FROM documents WHERE id = ?',
args: [id],
});
if (result.rows.length === 0) return null;
const r = result.rows[0];
return {
id: r[0] as string,
title: r[1] as string,
content: r[2] as string,
file_path: r[3] as string,
category: r[4] as string | null,
doc_date: r[5] as string | null,
created_at: r[6] as number,
updated_at: r[7] as number,
};
}
export async function upsertDocument(doc: Omit<Document, 'created_at' | 'updated_at'> & { created_at?: number }): Promise<void> {
const db = getKanbanDb();
const now = Date.now();
await db.execute({
sql: `INSERT INTO documents (id, title, content, file_path, category, doc_date, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(file_path) DO UPDATE SET
title = excluded.title,
content = excluded.content,
category = excluded.category,
doc_date = excluded.doc_date,
updated_at = excluded.updated_at`,
args: [doc.id, doc.title, doc.content, doc.file_path, doc.category ?? null, doc.doc_date ?? null, doc.created_at ?? now, now],
});
}
export async function getCategories(): Promise<string[]> {
const db = getKanbanDb();
const result = await db.execute(
'SELECT DISTINCT category FROM documents WHERE category IS NOT NULL ORDER BY category'
);
return result.rows.map((r) => r[0] as string);
}
Step 3: 빌드 확인
cd /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/content-orchestration
npm run build 2>&1 | tail -20
Expected: 빌드 성공 (kanban-db.ts 타입 에러 없음)
Step 4: Commit
git add src/lib/kanban-db.ts .env
git commit -m "feat: add kanban DB client with documents schema"
Task 2: 마이그레이션 스크립트 (로컬 docs → kanban DB)
Files:
- Create:
scripts/migrate-docs.mjs - Modify:
package.json(scripts에 sync-docs 추가)
Step 1: scripts/migrate-docs.mjs 생성
// scripts/migrate-docs.mjs
// 실행: node scripts/migrate-docs.mjs
// 필요 env: KANBAN_DB_URL, KANBAN_DB_TOKEN, DOCS_ROOT (기본값: business-builder/docs)
import { createClient } from '@libsql/client/web';
import { readdir, readFile, stat } from 'fs/promises';
import { join, relative, basename, extname } from 'path';
import { randomUUID } from 'crypto';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DOCS_ROOT = process.env.DOCS_ROOT
?? join(__dirname, '..', '..', '..', '..', 'docs');
const db = createClient({
url: process.env.KANBAN_DB_URL,
authToken: process.env.KANBAN_DB_TOKEN,
});
// 파일 경로에서 카테고리/날짜 추출
// 예: docs/plans/2026/03/03/file.md → category=plans, doc_date=2026-03-03
function parsePathInfo(filePath) {
const rel = relative(DOCS_ROOT, filePath);
const parts = rel.split('/');
let category = null;
let doc_date = null;
if (parts.length >= 1) category = parts[0]; // plans, reports, strategy 등
// YYYY/MM/DD 패턴 찾기
const dateMatch = rel.match(/(\d{4})\/(\d{2})\/(\d{2})/);
if (dateMatch) {
doc_date = `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`;
}
return { category, doc_date };
}
// 마크다운에서 첫 번째 h1 제목 추출
function extractTitle(content, filePath) {
const h1Match = content.match(/^#\s+(.+)$/m);
if (h1Match) return h1Match[1].trim();
// h1 없으면 파일명 사용
return basename(filePath, extname(filePath))
.replace(/-/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
// 디렉토리 재귀 탐색
async function* walkDir(dir) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
yield* walkDir(fullPath);
} else if (entry.name.endsWith('.md')) {
yield fullPath;
}
}
}
async function main() {
// 테이블 생성
await db.execute(`
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
file_path TEXT NOT NULL UNIQUE,
category TEXT,
doc_date TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
let count = 0;
let errors = 0;
for await (const filePath of walkDir(DOCS_ROOT)) {
try {
const content = await readFile(filePath, 'utf-8');
const title = extractTitle(content, filePath);
const { category, doc_date } = parsePathInfo(filePath);
const relPath = relative(DOCS_ROOT, filePath);
const now = Date.now();
await db.execute({
sql: `INSERT INTO documents (id, title, content, file_path, category, doc_date, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(file_path) DO UPDATE SET
title = excluded.title,
content = excluded.content,
category = excluded.category,
doc_date = excluded.doc_date,
updated_at = excluded.updated_at`,
args: [randomUUID().replace(/-/g, '').slice(0, 25), title, content, relPath, category, doc_date, now, now],
});
count++;
process.stdout.write(`\r${count}개 처리 중...`);
} catch (err) {
console.error(`\n오류 (${filePath}):`, err.message);
errors++;
}
}
console.log(`\n\n완료: ${count}개 업서트, 오류 ${errors}개`);
process.exit(0);
}
main().catch((err) => { console.error(err); process.exit(1); });
Step 2: package.json에 sync-docs 스크립트 추가
package.json의 "scripts" 섹션에 추가:
"sync-docs": "DOCS_ROOT=/Users/nbs22/(Claude)/(claude).projects/business-builder/docs node scripts/migrate-docs.mjs"
Step 3: 마이그레이션 실행 (로컬)
cd /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/content-orchestration
KANBAN_DB_URL="libsql://kanban-migkjy.aws-ap-northeast-1.turso.io" \
KANBAN_DB_TOKEN="eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzE2ODE3MzgsImlkIjoiYTZlYzYxYjAtYjNkNC00YzVkLThhZjYtNTVkZTU4M2RhYTRmIiwicmlkIjoiZTQ3OGNiZGQtNGU1My00NTgzLWE4MzktMjY4YzU1ZTBlNTQ0In0.YIItPetx8vhqqFDfQen-PeXXFhF4g7elt9koDR5xHvGxikvmITAxTYf2QixYCq3PtyJhaR9S5JlWElF9eLoODQ" \
DOCS_ROOT="/Users/nbs22/(Claude)/(claude).projects/business-builder/docs" \
npm run sync-docs
Expected: 완료: 72개 업서트, 오류 0개 (또는 유사한 숫자)
Step 4: DB 확인
KANBAN_DB_URL="libsql://kanban-migkjy.aws-ap-northeast-1.turso.io" \
KANBAN_DB_TOKEN="eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzE2ODE3MzgsImlkIjoiYTZlYzYxYjAtYjNkNC00YzVkLThhZjYtNTVkZTU4M2RhYTRmIiwicmlkIjoiZTQ3OGNiZGQtNGU1My00NTgzLWE4MzktMjY4YzU1ZTBlNTQ0In0.YIItPetx8vhqqFDfQen-PeXXFhF4g7elt9koDR5xHvGxikvmITAxTYf2QixYCq3PtyJhaR9S5JlWElF9eLoODQ" \
node -e "
const { createClient } = await import('@libsql/client/web');
const db = createClient({ url: process.env.KANBAN_DB_URL, authToken: process.env.KANBAN_DB_TOKEN });
const r = await db.execute('SELECT COUNT(*) FROM documents');
console.log('총 문서 수:', r.rows[0][0]);
"
Expected: 총 문서 수: 72 이상
Step 5: Commit
git add scripts/migrate-docs.mjs package.json
git commit -m "feat: add docs migration script with npm run sync-docs"
Task 3: /documents 목록 페이지
Files:
- Create:
src/app/documents/page.tsx
Step 1: src/app/documents/page.tsx 생성
import Link from 'next/link';
import { getDocuments, getCategories, ensureDocumentsSchema } from '@/lib/kanban-db';
export const revalidate = 300;
const CATEGORY_LABELS: Record<string, string> = {
plans: '플랜',
reports: '보고서',
strategy: '전략',
docs: '문서',
};
export default async function DocumentsPage({
searchParams,
}: {
searchParams: Promise<{ category?: string }>;
}) {
await ensureDocumentsSchema().catch(() => {});
const { category } = await searchParams;
const [documents, categories] = await Promise.all([
getDocuments(category).catch(() => []),
getCategories().catch(() => []),
]);
return (
<div className="min-h-screen bg-gray-50">
{/* 헤더 */}
<div className="bg-white border-b border-gray-200">
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="flex items-center justify-between">
<div>
<Link href="/" className="text-sm text-blue-600 hover:underline mb-1 block">
← 대시보드
</Link>
<h1 className="text-2xl font-bold text-gray-900">문서 허브</h1>
<p className="text-sm text-gray-500 mt-1">플랜·보고서·전략 문서 전체 {documents.length}건</p>
</div>
</div>
{/* 카테고리 필터 */}
<div className="flex gap-2 mt-4 flex-wrap">
<Link
href="/documents"
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
!category ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
전체
</Link>
{categories.map((cat) => (
<Link
key={cat}
href={`/documents?category=${cat}`}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
category === cat ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{CATEGORY_LABELS[cat] ?? cat}
</Link>
))}
</div>
</div>
</div>
{/* 목록 */}
<div className="max-w-5xl mx-auto px-4 py-6">
{documents.length === 0 ? (
<div className="text-center py-20 text-gray-400">문서가 없습니다.</div>
) : (
<div className="space-y-2">
{documents.map((doc) => (
<Link
key={doc.id}
href={`/documents/${doc.id}`}
className="flex items-center justify-between bg-white border border-gray-200 rounded-lg px-5 py-4 hover:border-blue-300 hover:shadow-sm transition-all group"
>
<div className="min-w-0">
<p className="font-medium text-gray-900 truncate group-hover:text-blue-600">
{doc.title}
</p>
<p className="text-xs text-gray-400 mt-0.5 truncate">{doc.file_path}</p>
</div>
<div className="flex items-center gap-3 ml-4 shrink-0">
{doc.doc_date && (
<span className="text-xs text-gray-400">{doc.doc_date}</span>
)}
{doc.category && (
<span className="text-xs bg-blue-50 text-blue-600 px-2 py-0.5 rounded-full">
{CATEGORY_LABELS[doc.category] ?? doc.category}
</span>
)}
<span className="text-gray-300">→</span>
</div>
</Link>
))}
</div>
)}
</div>
</div>
);
}
Step 2: 로컬에서 확인
npm run dev
브라우저에서 http://localhost:3000/documents 접속.
- 문서 목록이 표시되는지 확인
- 카테고리 필터 클릭 시 필터링 되는지 확인
Step 3: Commit
git add src/app/documents/page.tsx
git commit -m "feat: add /documents list page with category filter"
Task 4: /documents/[id] 상세 페이지 (마크다운 렌더링)
Files:
- Create:
src/app/documents/[id]/page.tsx
Step 1: src/app/documents/[id]/page.tsx 생성
import { notFound } from 'next/navigation';
import Link from 'next/link';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { getDocument } from '@/lib/kanban-db';
type Props = { params: Promise<{ id: string }> };
export default async function DocumentDetailPage({ params }: Props) {
const { id } = await params;
const doc = await getDocument(id).catch(() => null);
if (!doc) notFound();
return (
<div className="min-h-screen bg-gray-50">
{/* 헤더 */}
<div className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<Link href="/documents" className="text-sm text-blue-600 hover:underline">
← 목록으로
</Link>
<div className="flex items-center gap-3 text-xs text-gray-400">
{doc.doc_date && <span>{doc.doc_date}</span>}
{doc.category && (
<span className="bg-blue-50 text-blue-600 px-2 py-0.5 rounded-full">
{doc.category}
</span>
)}
</div>
</div>
</div>
{/* 본문 */}
<div className="max-w-4xl mx-auto px-4 py-10">
<article className="bg-white rounded-xl border border-gray-200 px-8 py-10 prose prose-gray max-w-none
prose-headings:font-bold prose-h1:text-2xl prose-h2:text-xl
prose-a:text-blue-600 prose-code:bg-gray-100 prose-code:rounded prose-code:px-1
prose-pre:bg-gray-900 prose-pre:text-gray-100">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{doc.content}
</ReactMarkdown>
</article>
<div className="mt-6 text-xs text-gray-400 text-center">
{doc.file_path}
</div>
</div>
</div>
);
}
Step 2: 로컬 확인
브라우저에서 목록에서 문서 클릭 → 상세 페이지 마크다운 렌더링 확인.
- 코드 블록, 테이블, 헤딩 스타일 확인
- 뒤로가기 링크 동작 확인
Step 3: Commit
git add src/app/documents/[id]/page.tsx
git commit -m "feat: add /documents/[id] detail page with markdown rendering"
Task 5: 대시보드 홈에 Docs 링크 추가
Files:
- Modify:
src/app/page.tsx
Step 1: 홈 페이지에 Docs 링크 추가
src/app/page.tsx의 return 상단에 문서 허브 링크 추가.
기존 렌더 구조 확인 후, 헤더 또는 퀵링크 영역에 아래 추가:
<Link
href="/documents"
className="inline-flex items-center gap-2 bg-blue-50 text-blue-700 px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-100 transition-colors"
>
📄 문서 허브
</Link>
위치: 기존 캠페인 목록 상단, <h2> 태그나 첫 번째 섹션 이전.
Step 2: 확인
npm run build 2>&1 | tail -5
Expected: ✓ Compiled successfully
Step 3: Commit
git add src/app/page.tsx
git commit -m "feat: add docs hub link to dashboard home"
Task 6: Vercel 환경변수 추가 + 배포
Step 1: Vercel 환경변수 추가
Vercel 대시보드 → content-orchestration 프로젝트 → Settings → Environment Variables에 추가:
KANBAN_DB_URL=libsql://kanban-migkjy.aws-ap-northeast-1.turso.ioKANBAN_DB_TOKEN= (Task 1의 토큰 값)
또는 Vercel CLI로:
vercel env add KANBAN_DB_URL production
vercel env add KANBAN_DB_TOKEN production
Step 2: Push
git push origin main
Vercel 자동 배포 대기 (약 2분).
Step 3: 배포 후 확인
open https://content-orchestration.vercel.app/documents
Expected:
- 문서 목록 표시
- 카테고리 필터 동작
- 문서 클릭 → 마크다운 렌더링
Step 4: 최종 보고
cd /Users/nbs22/(Claude)/(claude).projects/business-builder
./scripts/vice-reply.sh "✅ 문서 접근성 시스템 배포 완료
URL: https://content-orchestration.vercel.app/documents
- 총 X개 문서 마이그레이션 완료
- 카테고리 필터: plans/reports/strategy
- 마크다운 렌더링 (react-markdown + GFM)
- 공개 링크 (인증 없음)
- 새 문서 추가 시: npm run sync-docs 실행" "docs-system-complete"
실행 후 문서 추가 방법
새로운 .md 파일이 docs/ 하위에 추가되면:
cd /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/content-orchestration
npm run sync-docs
자동으로 kanban DB에 upsert → content-orchestration에서 즉시 확인 가능.