feat: revamp to dynamic database-first architecture and cleanup phase docs
CI / typecheck + build (turbo) (push) Canceled after 0s

- Migrate document fetching and CRUD to be PostgreSQL-authoritative
- Remove static section enums and add dynamic listSections query
- Support custom sections and metadata across API, MCP, and Web UI
- Add /api/sections endpoint and update Header, Sidebar, and Forms
- Remove obsolete phase planning docs and modernize README/AGENTS
This commit is contained in:
asepharyana
2026-08-21 11:35:42 +07:00
parent a937e8f51b
commit a9b67385c3
37 changed files with 720 additions and 2515 deletions
+8 -17
View File
@@ -8,7 +8,7 @@ import DocForm from "@/components/DocForm";
import TOC from "@/components/TOC";
import DocActions from "@/components/DocActions";
import { classifyPath, extractFoldersForSection } from "@mcpedia/core";
import { SECTIONS_BY_ID } from "@mcpedia/config/sections";
import { getSectionMeta } from "@mcpedia/config";
import type { DocumentMeta } from "@mcpedia/core";
export const dynamic = "force-dynamic";
@@ -19,17 +19,7 @@ interface DocPageProps {
}
function getSectionInfo(section: string) {
const info = SECTIONS_BY_ID.get(section);
if (!info) {
return {
label: section.charAt(0).toUpperCase() + section.slice(1),
icon: "📄",
};
}
return {
label: info.label,
icon: info.icon,
};
return getSectionMeta(section);
}
function calculateReadingTime(text: string): string {
@@ -128,9 +118,9 @@ function FolderIndexPage({
}) {
const prefix = `${section}/${slug}/`;
const folderDocPaths = allDocs
.filter((d) => d.path.startsWith(prefix))
.filter((d) => d.slug.startsWith(prefix))
.map((d) => ({
rel: d.path.slice(prefix.length).replace(/\.md$/, ""),
rel: d.slug.slice(prefix.length),
doc: d,
}))
.sort((a, b) => a.rel.localeCompare(b.rel));
@@ -292,8 +282,8 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
const fullSlug = `${section}/${slug.join("/")}`;
const allDocs = await listDocuments();
const docPaths = allDocs.map((d) => d.path);
const classification = classifyPath(docPaths, fullSlug);
const docSlugs = allDocs.map((d) => d.slug);
const classification = classifyPath(docSlugs, fullSlug);
if (classification === "folder") {
return <FolderIndexPage section={section} slug={slug.join("/")} allDocs={allDocs} />;
@@ -312,7 +302,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
]);
if (edit === "1" && canEdit) {
const existingFolders = extractFoldersForSection(docPaths, doc.section);
const existingFolders = extractFoldersForSection(docSlugs, doc.section);
return (
<div>
<Link
@@ -328,6 +318,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
mode="edit"
slug={fullSlug}
secret={WEBHOOK_SECRET}
existingSections={Array.from(new Set(allDocs.map((d) => d.section)))}
existingFolders={{
[doc.section]: existingFolders,
}}
+8 -18
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { listDocuments } from "@mcpedia/core";
import { SECTIONS } from "@mcpedia/config/sections";
import { getSectionMeta } from "@mcpedia/config";
export const dynamic = "force-dynamic";
@@ -24,12 +24,11 @@ interface TreeNode {
function buildFolderTree(docs: DocMeta[], section: string): TreeNode[] {
const root: TreeNode[] = [];
for (const doc of docs.filter((d) => d.section === section)) {
const rel = doc.path.startsWith(`${section}/`)
? doc.path.slice(section.length + 1)
: doc.path;
const cleanPath = rel.replace(/\.md$/, "");
const parts = cleanPath.split("/");
for (const doc of docs.filter((d) => (d.section || "").toLowerCase() === section.toLowerCase())) {
const rel = doc.slug.startsWith(`${section}/`)
? doc.slug.slice(section.length + 1)
: doc.slug;
const parts = rel.split("/");
let current = root;
let currentPath = section;
@@ -118,19 +117,10 @@ export default async function SectionIndexPage({
params: Promise<{ section: string }>;
}) {
const { section } = await params;
const sectionInfo = SECTIONS.find((s) => s.id === section);
if (!sectionInfo) {
return (
<div className="py-12 text-center">
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">Unknown section</h1>
<p className="text-sm text-[var(--text-muted)] mt-2">Section &quot;{section}&quot; does not exist.</p>
</div>
);
}
const sectionInfo = getSectionMeta(section);
const allDocs = await listDocuments();
const sectionDocs = allDocs.filter((d) => d.section === section);
const sectionDocs = allDocs.filter((d) => (d.section || "").toLowerCase() === section.toLowerCase());
const tree = buildFolderTree(allDocs, section);
return (
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { listSections } from "@mcpedia/core";
export const dynamic = "force-dynamic";
// GET /api/sections — list all active sections with document counts dynamically from PostgreSQL.
export async function GET() {
try {
const sections = await listSections();
return NextResponse.json(sections);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+47 -29
View File
@@ -9,20 +9,21 @@ interface DocFormProps {
slug?: string;
secret: string;
existingFolders?: Record<string, string[]>;
existingSections?: string[];
initial?: {
title?: string;
body?: string;
section?: "docs" | "writeups" | "research" | "notes";
type?: "documentation" | "writeup" | "research" | "note";
status?: "published" | "draft";
section?: string;
type?: string;
status?: "published" | "draft" | string;
tags?: string[];
author?: string;
extraFields?: Record<string, unknown>;
};
}
const SECTION_OPTIONS = ["docs", "writeups", "research", "notes"] as const;
const TYPE_OPTIONS = ["documentation", "writeup", "research", "note"] as const;
const DEFAULT_SECTION_LIST = ["docs", "writeups", "research", "notes", "guides", "tutorials", "ctf", "api", "projects"];
const DEFAULT_TYPE_LIST = ["documentation", "writeup", "research", "note", "guide", "tutorial", "spec"];
const PRESET_FIELDS = [
{ key: "difficulty", value: "medium" },
@@ -37,6 +38,7 @@ export default function DocForm({
slug,
secret,
existingFolders,
existingSections,
initial,
}: DocFormProps) {
const router = useRouter();
@@ -56,6 +58,10 @@ export default function DocForm({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const sectionOptions = Array.from(
new Set([...(existingSections ?? []), ...DEFAULT_SECTION_LIST, section].filter(Boolean)),
);
// Extract existing custom fields
const [customFields, setCustomFields] = useState<Array<{ key: string; value: string }>>(() => {
if (initial?.extraFields) {
@@ -198,7 +204,7 @@ export default function DocForm({
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. DEF CON Quals 2024 — pwn-100 Writeup"
placeholder="e.g. Database Architecture Overview"
className={`${baseInputCls} text-base`}
required
/>
@@ -210,34 +216,46 @@ export default function DocForm({
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
Section
</label>
<select
value={section}
onChange={(e) => setSection(e.target.value as typeof section)}
className={baseInputCls}
disabled={isEdit}
>
{SECTION_OPTIONS.map((s) => (
<option key={s} value={s}>
{s.toUpperCase()}
</option>
))}
</select>
<div className="relative">
<input
type="text"
list="section-list"
value={section}
onChange={(e) => setSection(e.target.value)}
placeholder="e.g. docs, writeups, ctf, guides"
className={baseInputCls}
required
/>
<datalist id="section-list">
{sectionOptions.map((s) => (
<option key={s} value={s}>
{s.toUpperCase()}
</option>
))}
</datalist>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
Type
</label>
<select
value={type}
onChange={(e) => setType(e.target.value as typeof type)}
className={baseInputCls}
>
{TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<div className="relative">
<input
type="text"
list="type-list"
value={type}
onChange={(e) => setType(e.target.value)}
placeholder="e.g. documentation, writeup, note"
className={baseInputCls}
/>
<datalist id="type-list">
{DEFAULT_TYPE_LIST.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</datalist>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
+16 -4
View File
@@ -1,15 +1,27 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { SECTIONS } from "@mcpedia/config/sections";
import { DEFAULT_SECTIONS, type SectionConfig } from "@mcpedia/config/sections";
import ThemeToggle from "@/components/ThemeToggle";
import CommandMenu from "@/components/CommandMenu";
export default function Header() {
const pathname = usePathname();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [sections, setSections] = useState<SectionConfig[]>(DEFAULT_SECTIONS);
useEffect(() => {
fetch("/api/sections")
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data) && data.length > 0) {
setSections(data);
}
})
.catch(() => {});
}, []);
return (
<header className="sticky top-0 z-30 border-b border-[var(--border-color)] glass-nav">
@@ -31,7 +43,7 @@ export default function Header() {
{/* Desktop Nav */}
<nav className="hidden md:flex items-center gap-1">
{SECTIONS.map((s) => {
{sections.map((s) => {
const isActive = pathname === `/${s.id}` || pathname.startsWith(`/${s.id}/`);
return (
<Link
@@ -107,7 +119,7 @@ export default function Header() {
{/* Mobile Drawer */}
{mobileMenuOpen && (
<div className="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-surface)] px-4 py-3 space-y-1 animate-fade-in shadow-lg">
{SECTIONS.map((s) => (
{sections.map((s) => (
<Link
key={s.id}
href={`/${s.id}`}
+15 -4
View File
@@ -3,7 +3,7 @@
import { useEffect, useState, useMemo } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { SECTIONS } from "@mcpedia/config/sections";
import { getSectionMeta } from "@mcpedia/config/sections";
interface Doc {
slug: string;
@@ -200,6 +200,17 @@ export default function Sidebar() {
);
}, [docs, filterText]);
const distinctSections = useMemo(() => {
const map = new Map<string, { id: string; label: string; icon: string }>();
for (const doc of filteredDocs) {
const s = (doc.section || "docs").toLowerCase();
if (!map.has(s)) {
map.set(s, getSectionMeta(s));
}
}
return Array.from(map.values()).sort((a, b) => a.label.localeCompare(b.label));
}, [filteredDocs]);
return (
<nav className="h-full flex flex-col py-4 px-3">
{/* Sidebar search filter */}
@@ -233,14 +244,14 @@ export default function Sidebar() {
{/* Summary count */}
<div className="flex items-center justify-between px-1 mb-3 text-[11px] text-[var(--text-dim)]">
<span className="uppercase tracking-wider font-mono">Documentation</span>
<span className="uppercase tracking-wider font-mono">Knowledge Base</span>
<span>{filteredDocs.length} items</span>
</div>
{/* Section Trees */}
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
{SECTIONS.map(({ id, label, icon }) => {
const sectionDocs = filteredDocs.filter((d) => d.section === id);
{distinctSections.map(({ id, label, icon }) => {
const sectionDocs = filteredDocs.filter((d) => (d.section || "docs").toLowerCase() === id);
if (sectionDocs.length === 0) return null;
const tree = buildFolderTree(filteredDocs, id);
+11 -6
View File
@@ -1,6 +1,5 @@
import Link from "next/link";
import { listDocuments, extractFoldersForSection } from "@mcpedia/core";
import { SECTIONS } from "@mcpedia/config/sections";
import { listDocuments, extractFoldersForSection, listSections } from "@mcpedia/core";
import { WEBHOOK_SECRET } from "@mcpedia/config";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
@@ -13,8 +12,13 @@ export default async function CreatePage() {
const isAdmin = cookieStore.get("mcpedia_admin")?.value != null;
if (!isAdmin) redirect("/login");
const all = await listDocuments();
const docPaths = all.map((d) => d.path);
const [all, sections] = await Promise.all([
listDocuments(),
listSections(),
]);
const docSlugs = all.map((d) => d.slug);
const sectionIds = Array.from(new Set([...sections.map((s) => s.id), ...all.map((d) => d.section)]));
return (
<div className="space-y-6">
@@ -36,7 +40,7 @@ export default async function CreatePage() {
<div>
<h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">Create Document</h1>
<p className="text-xs text-[var(--text-muted)] mt-0.5">
Writes markdown file to disk, records revision, and computes 2048-dim vector embeddings.
Writes directly to PostgreSQL, records revision history, and computes vector embeddings.
</p>
</div>
</div>
@@ -45,8 +49,9 @@ export default async function CreatePage() {
<DocForm
mode="create"
secret={WEBHOOK_SECRET}
existingSections={sectionIds}
existingFolders={Object.fromEntries(
SECTIONS.map((s) => [s.id, extractFoldersForSection(docPaths, s.id)])
sectionIds.map((sid) => [sid, extractFoldersForSection(docSlugs, sid)])
)}
/>
</div>
+16 -15
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { listDocuments } from "@mcpedia/core";
import { SECTIONS } from "@mcpedia/config/sections";
import { listDocuments, listSections } from "@mcpedia/core";
import { getSectionMeta, type SectionConfig } from "@mcpedia/config";
import McpConfigSnippet from "@/components/McpConfigSnippet";
export const dynamic = "force-dynamic";
@@ -116,8 +116,6 @@ function SectionTree({ section, docs, pathname }: SectionTreeProps) {
docs.filter((d) => d.slug.startsWith(`${section}/`)),
section,
);
const sectionInfo = SECTIONS.find((s) => s.id === section);
if (!sectionInfo) return null;
return (
<div>
@@ -131,7 +129,10 @@ function SectionTree({ section, docs, pathname }: SectionTreeProps) {
}
export default async function HomePage() {
const all = await listDocuments();
const [all, sections] = await Promise.all([
listDocuments(),
listSections(),
]);
const recent = [...all]
.sort(
@@ -146,7 +147,7 @@ export default async function HomePage() {
<section className="relative pt-4 pb-2">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[#5e6ad2]/10 border border-[#5e6ad2]/25 text-[#5e6ad2] dark:text-[#7170ff] text-xs font-medium mb-6">
<span className="w-2 h-2 rounded-full bg-[#5e6ad2] animate-pulse" />
<span>Model Context Protocol + Unified Knowledge Base</span>
<span>Model Context Protocol + PostgreSQL Core</span>
</div>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-[var(--text-primary)] tracking-tight mb-5 leading-tight">
@@ -157,7 +158,7 @@ export default async function HomePage() {
</h1>
<p className="text-base sm:text-lg text-[var(--text-muted)] max-w-2xl leading-relaxed mb-8">
A high-performance, content-first documentation system. Browse hierarchical notes and CTF writeups in your browser, or connect AI coding assistants directly via native MCP tools.
A high-performance, database-backed documentation system. Browse hierarchical notes and technical writeups in your browser, or connect AI coding assistants directly via native MCP tools.
</p>
{/* Quick action buttons & stats */}
@@ -190,18 +191,18 @@ export default async function HomePage() {
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl max-w-3xl shadow-sm">
<div>
<div className="text-xl font-bold text-[var(--text-primary)]">{all.length}</div>
<div className="text-xs text-[var(--text-muted)]">Documents indexed</div>
<div className="text-xs text-[var(--text-muted)]">Documents in DB</div>
</div>
<div>
<div className="text-xl font-bold text-[var(--text-primary)]">{SECTIONS.length}</div>
<div className="text-xs text-[var(--text-muted)]">Core sections</div>
<div className="text-xl font-bold text-[var(--text-primary)]">{sections.length}</div>
<div className="text-xs text-[var(--text-muted)]">Active sections</div>
</div>
<div>
<div className="text-xl font-bold text-[#5e6ad2] dark:text-[#7170ff]">RRF Hybrid</div>
<div className="text-xs text-[var(--text-muted)]">FTS + 2048-dim vectors</div>
<div className="text-xs text-[var(--text-muted)]">FTS + Cosine Vectors</div>
</div>
<div>
<div className="text-xl font-bold text-emerald-600 dark:text-emerald-400">10 MCP Tools</div>
<div className="text-xl font-bold text-emerald-600 dark:text-emerald-400">13 MCP Tools</div>
<div className="text-xs text-[var(--text-muted)]">Streamable HTTP :4021</div>
</div>
</div>
@@ -216,8 +217,8 @@ export default async function HomePage() {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{SECTIONS.map((s) => {
const sectionDocs = all.filter((d) => d.section === s.id);
{sections.map((s) => {
const sectionDocs = all.filter((d) => (d.section || "docs").toLowerCase() === s.id);
return (
<div
key={s.id}
@@ -272,7 +273,7 @@ export default async function HomePage() {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{recent.map((d) => {
const sInfo = SECTIONS.find((s) => s.id === d.section);
const sInfo = getSectionMeta(d.section);
return (
<Link
key={d.slug}
+14 -2
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { SECTIONS } from "@mcpedia/config/sections";
import { DEFAULT_SECTIONS, type SectionConfig } from "@mcpedia/config/sections";
interface DocHit {
slug: string;
@@ -24,10 +24,22 @@ function SearchContent() {
const [mode, setMode] = useState<SearchMode>(
["hybrid", "keyword", "semantic"].includes(initialMode) ? initialMode : "hybrid",
);
const [sections, setSections] = useState<SectionConfig[]>(DEFAULT_SECTIONS);
const [selectedSection, setSelectedSection] = useState<string>("all");
const [results, setResults] = useState<DocHit[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetch("/api/sections")
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data) && data.length > 0) {
setSections(data);
}
})
.catch(() => {});
}, []);
const handleSearch = useCallback(async (q: string, searchMode: SearchMode) => {
if (!q.trim()) {
setResults([]);
@@ -154,7 +166,7 @@ function SearchContent() {
>
All Sections
</button>
{SECTIONS.map((s) => (
{sections.map((s) => (
<button
key={s.id}
type="button"