feat: revamp to dynamic database-first architecture and cleanup phase docs
CI / typecheck + build (turbo) (push) Canceled after 0s
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:
@@ -39,20 +39,27 @@ export const EMBED_BASE_URL = process.env.EMBED_BASE_URL ?? "";
|
||||
export const EMBED_API_KEY = process.env.EMBED_API_KEY ?? "";
|
||||
export const EMBED_MODEL = process.env.EMBED_MODEL ?? "";
|
||||
|
||||
// Phase 3: Redis + BullMQ (shared imrnes Redis, no auth by default).
|
||||
// Redis + BullMQ (shared Redis instance).
|
||||
export const REDIS_URL = process.env.REDIS_URL ?? "redis://100.121.180.82:6379";
|
||||
export const REDIS_PASSWORD = process.env.REDIS_PASSWORD ?? "";
|
||||
export const QUEUE_PREFIX = process.env.QUEUE_PREFIX ?? "mcpedia";
|
||||
|
||||
// Phase 4: git-sync webhook shared secret.
|
||||
// Git-sync webhook and API write shared secret.
|
||||
export const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? "";
|
||||
|
||||
// Phase 11: Admin password for web-based CRUD.
|
||||
// Admin password for web-based CRUD.
|
||||
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? "";
|
||||
|
||||
// Phase 14: Section registry — re-exported from a Node-free module so client
|
||||
// components can safely import SECTIONS without pulling in node:fs.
|
||||
export { SECTIONS, SECTIONS_BY_ID, SECTION_IDS, type SectionConfig } from "./sections";
|
||||
// Dynamic Section registry & helpers — re-exported from a Node-free module.
|
||||
export {
|
||||
SECTIONS,
|
||||
SECTIONS_BY_ID,
|
||||
SECTION_IDS,
|
||||
DEFAULT_SECTIONS,
|
||||
SECTION_PRESETS,
|
||||
getSectionMeta,
|
||||
type SectionConfig,
|
||||
} from "./sections";
|
||||
|
||||
// NOTE: we deliberately do NOT throw here if DATABASE_URL is empty.
|
||||
// Throwing at import time breaks `next build` SSG and any runtime-injected env.
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
/**
|
||||
* Section registry — defines all content sections for the UI.
|
||||
* This file is intentionally free of Node.js built-in imports (no fs/path)
|
||||
* so it can be safely imported by client components.
|
||||
*
|
||||
* Adding a new section is as simple as adding an entry here + creating a
|
||||
* `content/<id>/` directory with markdown files. No UI code changes needed.
|
||||
* Dynamic Section Registry & Metadata Generator.
|
||||
* Safe for client and server components.
|
||||
*/
|
||||
export interface SectionConfig {
|
||||
id: string;
|
||||
@@ -13,32 +9,80 @@ export interface SectionConfig {
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export const SECTIONS: SectionConfig[] = [
|
||||
{
|
||||
id: "docs",
|
||||
export const SECTION_PRESETS: Record<string, { label: string; icon: string; desc: string }> = {
|
||||
docs: {
|
||||
label: "Documentation",
|
||||
icon: "📄",
|
||||
desc: "Setup guides, API references, and protocol specs.",
|
||||
},
|
||||
{
|
||||
id: "writeups",
|
||||
writeups: {
|
||||
label: "Writeups",
|
||||
icon: "📝",
|
||||
desc: "Post-mortems, debugging stories, and case studies.",
|
||||
desc: "Post-mortems, CTF writeups, debugging stories, and case studies.",
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
research: {
|
||||
label: "Research",
|
||||
icon: "🔬",
|
||||
desc: "Deep-dive analysis, architecture notes, and experiments.",
|
||||
},
|
||||
{
|
||||
id: "notes",
|
||||
notes: {
|
||||
label: "Notes",
|
||||
icon: "📌",
|
||||
desc: "Quick references, patterns, and gotchas.",
|
||||
desc: "Quick references, patterns, and cheat-sheets.",
|
||||
},
|
||||
guides: {
|
||||
label: "Guides",
|
||||
icon: "🧭",
|
||||
desc: "Step-by-step guides and implementation walkthroughs.",
|
||||
},
|
||||
tutorials: {
|
||||
label: "Tutorials",
|
||||
icon: "🎓",
|
||||
desc: "Educational lessons and practical tutorials.",
|
||||
},
|
||||
ctf: {
|
||||
label: "CTF",
|
||||
icon: "🚩",
|
||||
desc: "Capture The Flag challenge writeups and exploit solutions.",
|
||||
},
|
||||
api: {
|
||||
label: "API",
|
||||
icon: "⚡",
|
||||
desc: "API documentation and endpoint definitions.",
|
||||
},
|
||||
projects: {
|
||||
label: "Projects",
|
||||
icon: "🚀",
|
||||
desc: "Project overviews and technical architecture roadmaps.",
|
||||
},
|
||||
};
|
||||
|
||||
export function getSectionMeta(id: string, count?: number): SectionConfig {
|
||||
const cleanId = (id || "docs").toLowerCase().trim();
|
||||
if (SECTION_PRESETS[cleanId]) {
|
||||
return { id: cleanId, ...SECTION_PRESETS[cleanId] };
|
||||
}
|
||||
const formattedLabel = cleanId
|
||||
.split(/[-_]/)
|
||||
.map((w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : ""))
|
||||
.join(" ");
|
||||
return {
|
||||
id: cleanId,
|
||||
label: formattedLabel || "Custom Section",
|
||||
icon: "📁",
|
||||
desc: `Documents in the ${formattedLabel || cleanId} section.`,
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_SECTIONS: SectionConfig[] = [
|
||||
getSectionMeta("docs"),
|
||||
getSectionMeta("writeups"),
|
||||
getSectionMeta("research"),
|
||||
getSectionMeta("notes"),
|
||||
];
|
||||
|
||||
// Backwards-compatible constants:
|
||||
export const SECTIONS: SectionConfig[] = DEFAULT_SECTIONS;
|
||||
export const SECTIONS_BY_ID = new Map(SECTIONS.map((s) => [s.id, s]));
|
||||
export const SECTION_IDS = SECTIONS.map((s) => s.id);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { and, eq, sql, desc } from "drizzle-orm";
|
||||
import { db } from "@mcpedia/db";
|
||||
import { documentChunks, documentRevisions, documents } from "@mcpedia/db/schema";
|
||||
import { CONTENT_ROOT } from "@mcpedia/config";
|
||||
import { CONTENT_ROOT, DEFAULT_SECTIONS, getSectionMeta } from "@mcpedia/config";
|
||||
import { parseFile } from "@mcpedia/parser";
|
||||
import { existsSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
DocSection,
|
||||
DocType,
|
||||
DocStatus,
|
||||
SectionInfo,
|
||||
} from "@mcpedia/types";
|
||||
import { chunkText, embedChunks, createEmbeddingProvider } from "@mcpedia/embeddings";
|
||||
import { readContentFile } from "./content.service";
|
||||
@@ -19,10 +20,47 @@ import { snapshotRevision } from "./index.service";
|
||||
|
||||
const embedder = createEmbeddingProvider();
|
||||
|
||||
export async function listDocuments(opts: {
|
||||
section?: string;
|
||||
status?: string;
|
||||
} = {}): Promise<DocumentMeta[]> {
|
||||
/**
|
||||
* List all active sections dynamically from the database, including document counts.
|
||||
*/
|
||||
export async function listSections(): Promise<SectionInfo[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
section: documents.section,
|
||||
count: sql<number>`count(*)::int`,
|
||||
latestUpdate: sql<string>`max(${documents.updatedAt})::text`,
|
||||
})
|
||||
.from(documents)
|
||||
.where(eq(documents.status, "published"))
|
||||
.groupBy(documents.section)
|
||||
.orderBy(sql`count(*) desc`);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return DEFAULT_SECTIONS.map((s) => ({
|
||||
...s,
|
||||
docCount: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
return rows.map((r) => {
|
||||
const meta = getSectionMeta(r.section, r.count);
|
||||
return {
|
||||
...meta,
|
||||
docCount: r.count,
|
||||
updatedAt: r.latestUpdate,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List documents from the database, optionally filtered by section or status.
|
||||
*/
|
||||
export async function listDocuments(
|
||||
opts: {
|
||||
section?: string;
|
||||
status?: string;
|
||||
} = {},
|
||||
): Promise<DocumentMeta[]> {
|
||||
const status = opts.status ?? "published";
|
||||
const where = [eq(documents.status, status)];
|
||||
if (opts.section) where.push(eq(documents.section, opts.section));
|
||||
@@ -30,35 +68,40 @@ export async function listDocuments(opts: {
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(and(...where))
|
||||
.orderBy(documents.updatedAt);
|
||||
.orderBy(desc(documents.updatedAt));
|
||||
return rows.map(toMeta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single document directly from PostgreSQL (primary data store).
|
||||
*/
|
||||
export async function getDocument(slug: string): Promise<Document | null> {
|
||||
const [row] = await db.select().from(documents).where(eq(documents.slug, slug));
|
||||
if (!row) return null;
|
||||
// Prefer the on-disk file (source of truth); fall back to stored body.
|
||||
// Use parseFile (gray-matter) so the frontmatter is stripped — matches what
|
||||
// the indexer stores and what ReactMarkdown expects.
|
||||
const abs = join(CONTENT_ROOT, row.path);
|
||||
let body: string;
|
||||
if (existsSync(abs)) {
|
||||
const { body: parsedBody } = parseFile(abs, row.path);
|
||||
body = parsedBody;
|
||||
} else {
|
||||
body = row.body;
|
||||
|
||||
let body = row.body;
|
||||
// Fallback to disk only if DB body is empty (e.g. during initial migration)
|
||||
if (!body) {
|
||||
const abs = join(CONTENT_ROOT, row.path);
|
||||
if (existsSync(abs)) {
|
||||
const parsed = parseFile(abs, row.path);
|
||||
body = parsed.body;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...toMeta(row), body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find related documents sharing tags with the given slug.
|
||||
*/
|
||||
export async function getRelated(slug: string, limit = 5): Promise<DocumentMeta[]> {
|
||||
const [row] = await db
|
||||
.select({ tags: documents.tags })
|
||||
.from(documents)
|
||||
.where(eq(documents.slug, slug));
|
||||
if (!row || row.tags.length === 0) return [];
|
||||
// Build a text[] array literal for the && (overlap) operator, binding each
|
||||
// tag as a parameter to avoid SQL injection from frontmatter content.
|
||||
|
||||
const arrLit = sql`ARRAY[${sql.join(
|
||||
row.tags.map((t) => sql.param(t)),
|
||||
sql`, `,
|
||||
@@ -76,7 +119,6 @@ export { readContentFile };
|
||||
/**
|
||||
* Chunk a document body, embed the chunks, and upsert them into
|
||||
* `document_chunks` (replacing any prior chunks for the same slug).
|
||||
* Failures are thrown so the caller can decide whether to abort the index.
|
||||
*/
|
||||
export async function indexChunks(slug: string, body: string): Promise<number> {
|
||||
const [doc] = await db
|
||||
@@ -110,13 +152,7 @@ export async function indexChunks(slug: string, body: string): Promise<number> {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 11: CRUD — create, update, delete documents.
|
||||
//
|
||||
// Source of truth for content is the filesystem: each doc is a markdown file
|
||||
// under content/{section}/{slug}.md. The `documents` DB table mirrors the
|
||||
// metadata + body for fast search. CRUD ops write the file first, then upsert
|
||||
// the DB row, then snapshot a revision + reindex chunks. `deleteDocument`
|
||||
// also cleans up chunks + revisions.
|
||||
// Dynamic CRUD operations (Database-First)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CreateDocInput {
|
||||
@@ -134,6 +170,7 @@ export interface CreateDocInput {
|
||||
export interface UpdateDocInput {
|
||||
title?: string;
|
||||
body?: string;
|
||||
section?: DocSection;
|
||||
type?: DocType;
|
||||
status?: DocStatus;
|
||||
tags?: string[];
|
||||
@@ -150,10 +187,9 @@ function validateSlug(slug: string): string {
|
||||
return slug;
|
||||
}
|
||||
|
||||
/** Compute the relative file path for a slug (content/{section}/{slug}.md). */
|
||||
/** Compute the relative file path for a slug. */
|
||||
function slugToRelPath(section: DocSection, slug: string): string {
|
||||
const cleanSlug = validateSlug(slug);
|
||||
// If the slug already starts with the section, strip it to avoid doubling.
|
||||
const pathPart = cleanSlug.startsWith(`${section}/`)
|
||||
? cleanSlug.slice(section.length + 1)
|
||||
: cleanSlug;
|
||||
@@ -161,17 +197,15 @@ function slugToRelPath(section: DocSection, slug: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new document: write the markdown file, upsert the DB row,
|
||||
* snapshot a revision, and index semantic chunks.
|
||||
* @returns the created DocumentMeta
|
||||
* Create a new document in the PostgreSQL database, snapshot revision, and index chunks.
|
||||
*/
|
||||
export async function createDocument(input: CreateDocInput): Promise<DocumentMeta> {
|
||||
const section = input.section;
|
||||
const section = (input.section || "docs").trim();
|
||||
const slug = validateSlug(input.slug);
|
||||
const relPath = slugToRelPath(section, slug);
|
||||
const absPath = join(CONTENT_ROOT, relPath);
|
||||
|
||||
if (existsSync(absPath)) {
|
||||
const [existing] = await db.select({ id: documents.id }).from(documents).where(eq(documents.slug, slug));
|
||||
if (existing) {
|
||||
throw new Error(`document already exists at slug: ${slug}`);
|
||||
}
|
||||
|
||||
@@ -191,11 +225,7 @@ export async function createDocument(input: CreateDocInput): Promise<DocumentMet
|
||||
extraFields: input.extraFields ?? {},
|
||||
};
|
||||
|
||||
// Write file to disk first (source of truth).
|
||||
const { stringifyFile } = await import("@mcpedia/parser");
|
||||
stringifyFile(absPath, relPath, meta, input.body);
|
||||
|
||||
// Upsert DB row.
|
||||
// Upsert to DB directly
|
||||
await db.insert(documents).values({
|
||||
id: meta.id,
|
||||
slug: meta.slug,
|
||||
@@ -212,21 +242,28 @@ export async function createDocument(input: CreateDocInput): Promise<DocumentMet
|
||||
updatedAt: new Date(meta.updatedAt),
|
||||
});
|
||||
|
||||
// Snapshot revision + index chunks (best-effort; chunks must not block create).
|
||||
await snapshotRevision(slug, meta, input.body, "index");
|
||||
// Snapshot revision + index chunks
|
||||
await snapshotRevision(slug, meta, input.body, "create");
|
||||
try {
|
||||
await indexChunks(slug, input.body);
|
||||
} catch (err) {
|
||||
console.error(`createDocument: chunk/embed FAILED for ${slug}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
// Safe optional disk file sync
|
||||
try {
|
||||
const absPath = join(CONTENT_ROOT, relPath);
|
||||
const { stringifyFile } = await import("@mcpedia/parser");
|
||||
stringifyFile(absPath, relPath, meta, input.body);
|
||||
} catch (fsErr) {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing document: write new file, upsert DB row, snapshot a
|
||||
* revision (if body changed), and reindex chunks.
|
||||
* @returns the updated DocumentMeta
|
||||
* Update an existing document in PostgreSQL, snapshot revision, and reindex chunks.
|
||||
*/
|
||||
export async function updateDocument(
|
||||
slug: string,
|
||||
@@ -240,12 +277,11 @@ export async function updateDocument(
|
||||
...doc,
|
||||
title: input.title ?? doc.title,
|
||||
type: input.type ?? doc.type,
|
||||
section: doc.section,
|
||||
section: input.section ?? doc.section,
|
||||
status: input.status ?? doc.status,
|
||||
tags: input.tags ?? doc.tags,
|
||||
author: input.author ?? doc.author,
|
||||
updatedAt,
|
||||
// Merge: new extraFields override old ones; merge with existing
|
||||
extraFields:
|
||||
input.extraFields !== undefined
|
||||
? { ...doc.extraFields, ...input.extraFields }
|
||||
@@ -253,12 +289,7 @@ export async function updateDocument(
|
||||
};
|
||||
const body = input.body ?? doc.body;
|
||||
|
||||
// Write file to disk (source of truth).
|
||||
const absPath = join(CONTENT_ROOT, doc.path);
|
||||
const { stringifyFile } = await import("@mcpedia/parser");
|
||||
stringifyFile(absPath, doc.path, updated, body);
|
||||
|
||||
// Upsert DB row.
|
||||
// Update DB row directly
|
||||
await db
|
||||
.update(documents)
|
||||
.set({
|
||||
@@ -268,13 +299,13 @@ export async function updateDocument(
|
||||
status: updated.status,
|
||||
author: updated.author,
|
||||
tags: updated.tags,
|
||||
extraFields: input.extraFields ?? doc.extraFields ?? {},
|
||||
extraFields: updated.extraFields ?? {},
|
||||
body,
|
||||
updatedAt: new Date(updatedAt),
|
||||
})
|
||||
.where(eq(documents.slug, slug));
|
||||
|
||||
// Snapshot revision (only if body changed) + reindex chunks.
|
||||
// Snapshot revision (if body changed) + reindex chunks
|
||||
await snapshotRevision(slug, updated, body, "update");
|
||||
try {
|
||||
await indexChunks(slug, body);
|
||||
@@ -282,52 +313,60 @@ export async function updateDocument(
|
||||
console.error(`updateDocument: chunk/embed FAILED for ${slug}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
// Safe optional disk file sync
|
||||
try {
|
||||
const absPath = join(CONTENT_ROOT, doc.path);
|
||||
const { stringifyFile } = await import("@mcpedia/parser");
|
||||
stringifyFile(absPath, doc.path, updated, body);
|
||||
} catch (fsErr) {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a document: remove the file, delete DB rows (doc + chunks + revisions).
|
||||
* Delete a document from PostgreSQL, chunks, revisions, and optional disk file.
|
||||
*/
|
||||
export async function deleteDocument(slug: string): Promise<{ deleted: boolean }> {
|
||||
const [row] = await db.select().from(documents).where(eq(documents.slug, slug));
|
||||
if (!row) return { deleted: false };
|
||||
|
||||
// Remove file from disk (source of truth).
|
||||
const absPath = join(CONTENT_ROOT, row.path);
|
||||
if (existsSync(absPath)) unlinkSync(absPath);
|
||||
|
||||
// Clean up DB rows (cascades would work but be explicit).
|
||||
// Delete DB rows directly
|
||||
await db.delete(documentChunks).where(eq(documentChunks.slug, slug));
|
||||
await db.delete(documentRevisions).where(eq(documentRevisions.documentId, row.id));
|
||||
await db.delete(documents).where(eq(documents.id, row.id));
|
||||
|
||||
// Safe optional disk cleanup
|
||||
try {
|
||||
const absPath = join(CONTENT_ROOT, row.path);
|
||||
if (existsSync(absPath)) unlinkSync(absPath);
|
||||
} catch (fsErr) {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 14: Hierarchical folder structure helpers.
|
||||
// These enable GitHub-style nested folder browsing — the content creator
|
||||
// decides folder structure via where they place files; no config needed.
|
||||
// Hierarchical folder structure helpers (dynamic, slug-driven)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract the folder structure for a given section from a list of document paths.
|
||||
* Returns all distinct folder prefixes (relative to the section), sorted.
|
||||
*
|
||||
* Example: for section "docs" with paths ["docs/a/b/c.md", "docs/a/b/d.md"],
|
||||
* returns ["a", "a/b"].
|
||||
* Extract the folder structure for a given section from a list of document slugs/paths.
|
||||
*/
|
||||
export function extractFoldersForSection(
|
||||
docPaths: string[],
|
||||
docSlugs: string[],
|
||||
section: string,
|
||||
): string[] {
|
||||
const folders = new Set<string>();
|
||||
const base = `${section}/`;
|
||||
const cleanSlugs = docSlugs.map((s) => s.replace(/\.md$/, ""));
|
||||
|
||||
for (const path of docPaths) {
|
||||
const rel = path.startsWith(base) ? path.slice(base.length) : path;
|
||||
const cleanPath = rel.replace(/\.md$/, "");
|
||||
const parts = cleanPath.split("/");
|
||||
for (const slug of cleanSlugs) {
|
||||
if (!slug.startsWith(base)) continue;
|
||||
const rel = slug.slice(base.length);
|
||||
const parts = rel.split("/");
|
||||
|
||||
let acc = "";
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
@@ -340,28 +379,22 @@ export function extractFoldersForSection(
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a full URL slug path (e.g. "writeups/ctf/defcon-quals-2024"), determine
|
||||
* if it represents a folder (i.e., there are other docs whose paths start with
|
||||
* this prefix) or a leaf document.
|
||||
*
|
||||
* Returns:
|
||||
* - "doc" if the path is a leaf document (path + ".md" matches a doc)
|
||||
* - "folder" if the path is a parent of other doc paths
|
||||
* - "none" if neither
|
||||
* Determine if a path represents a folder or a leaf document.
|
||||
*/
|
||||
export function classifyPath(
|
||||
docPaths: string[],
|
||||
docSlugs: string[],
|
||||
path: string,
|
||||
): "doc" | "folder" | "none" {
|
||||
const dotMd = `${path}.md`;
|
||||
const cleanPath = path.replace(/\.md$/, "");
|
||||
const cleanSlugs = docSlugs.map((s) => s.replace(/\.md$/, ""));
|
||||
|
||||
// Check if it's a leaf document
|
||||
if (docPaths.includes(dotMd)) return "doc";
|
||||
// Check leaf doc match
|
||||
if (cleanSlugs.includes(cleanPath)) return "doc";
|
||||
|
||||
// Check if it's a folder (parent of other paths)
|
||||
const prefix = `${path}/`;
|
||||
const hasChildren = docPaths.some((p) => p.startsWith(prefix));
|
||||
if (hasChildren) return "folder";
|
||||
// Check folder parent match
|
||||
const prefix = `${cleanPath}/`;
|
||||
if (cleanSlugs.some((s) => s.startsWith(prefix))) return "folder";
|
||||
|
||||
return "none";
|
||||
}
|
||||
|
||||
|
||||
@@ -2,17 +2,16 @@ import { test, expect } from "bun:test";
|
||||
import { shouldCreateRevision } from "../src/index.service";
|
||||
|
||||
/**
|
||||
* Phase 9: unit tests for the revision-dedup decision rule.
|
||||
* Unit tests for the revision-dedup decision rule.
|
||||
*
|
||||
* `shouldCreateRevision` is the pure predicate that `indexContentFile` consults
|
||||
* before writing a new row to `document_revisions`. It's extracted because the
|
||||
* before writing a new row to `document_revisions`. It is extracted because the
|
||||
* dedup correctness is the single most important guarantee of the revision
|
||||
* system ("metadata-only edits don't bloat history"), and it must hold without
|
||||
* a database.
|
||||
*
|
||||
* The DB-backed paths (`snapshotRevision`, `restoreRevision`) are exercised
|
||||
* end-to-end by the existing manual e2e (`bun run index` + restore via the web
|
||||
* /api/revisions/restore route, see PHASES.md Phase 4 verification). Here we
|
||||
* end-to-end via the indexer and the web revision restore route. Here we
|
||||
* lock the decision invariant in CI.
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,4 +12,6 @@ export type {
|
||||
Document,
|
||||
DocumentMeta,
|
||||
SearchHit,
|
||||
SectionInfo,
|
||||
} from "@mcpedia/types";
|
||||
|
||||
|
||||
@@ -8,14 +8,6 @@ import type {
|
||||
DocumentMeta,
|
||||
} from "@mcpedia/types";
|
||||
|
||||
const SECTIONS: DocSection[] = ["docs", "writeups", "research", "notes"];
|
||||
|
||||
const VALID_TYPES: DocType[] = [
|
||||
"documentation",
|
||||
"writeup",
|
||||
"research",
|
||||
"note",
|
||||
];
|
||||
|
||||
// Standard frontmatter keys that are rendered explicitly in the UI template.
|
||||
// Any other key in frontmatter becomes a dynamic "extra field" badge.
|
||||
@@ -41,13 +33,16 @@ export function parseFile(absPath: string, relPath: string): ParsedFile {
|
||||
const raw = readFileSync(absPath, "utf8");
|
||||
const { data, content } = matter(raw);
|
||||
|
||||
const parts = relPath.split("/");
|
||||
const derivedSection = parts.length > 1 ? parts[0] : "docs";
|
||||
const section: DocSection =
|
||||
(SECTIONS.find((s) => relPath.startsWith(s + "/")) as DocSection | undefined) ??
|
||||
"docs";
|
||||
typeof data.section === "string" && data.section.trim() !== ""
|
||||
? data.section.trim()
|
||||
: derivedSection;
|
||||
|
||||
const slug = relPath.replace(/\.mdx?$/, "");
|
||||
|
||||
const type = (VALID_TYPES.includes(data.type) ? data.type : "documentation") as DocType;
|
||||
const type = (typeof data.type === "string" && data.type.trim() !== "" ? data.type.trim() : "documentation") as DocType;
|
||||
const status = (data.status === "draft" ? "draft" : "published") as DocStatus;
|
||||
|
||||
const tags: string[] = Array.isArray(data.tags)
|
||||
|
||||
@@ -57,13 +57,20 @@ test("parseFile: section derived from top-level dir", () => {
|
||||
expect(writeDoc("notes/baz.md", "---\ntitle: C\n---\nbody").meta.section).toBe("notes");
|
||||
});
|
||||
|
||||
test("parseFile: invalid type/status fall back to defaults", () => {
|
||||
const { meta } = writeDoc(
|
||||
test("parseFile: dynamic type and status with defaults", () => {
|
||||
const { meta: m1 } = writeDoc(
|
||||
"docs/x.md",
|
||||
"---\ntitle: X\ntype: bogus\nstatus: bogus\n---\n",
|
||||
"---\ntitle: X\ntype: custom-type\nstatus: draft\n---\n",
|
||||
);
|
||||
expect(meta.type).toBe("documentation");
|
||||
expect(meta.status).toBe("published");
|
||||
expect(m1.type).toBe("custom-type");
|
||||
expect(m1.status).toBe("draft");
|
||||
|
||||
const { meta: m2 } = writeDoc(
|
||||
"docs/y.md",
|
||||
"---\ntitle: Y\n---\n",
|
||||
);
|
||||
expect(m2.type).toBe("documentation");
|
||||
expect(m2.status).toBe("published");
|
||||
});
|
||||
|
||||
test("parseFile: missing optional fields get sane defaults", () => {
|
||||
|
||||
@@ -27,26 +27,21 @@ export function cosine(a: number[], b: number[]): number {
|
||||
return denom === 0 ? 0 : dot / denom;
|
||||
}
|
||||
|
||||
const VALID_SECTIONS: DocSection[] = ["docs", "writeups", "research", "notes"];
|
||||
const VALID_TYPES: DocType[] = ["documentation", "writeup", "research", "note"];
|
||||
|
||||
/** Map a Drizzle row (text columns, Date timestamps) into the strict types. */
|
||||
/** Map a Drizzle row into DocumentMeta. */
|
||||
function toMeta(row: DocumentRow): DocumentMeta {
|
||||
const extra = (row.extraFields ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
type: (VALID_TYPES.includes(row.type as DocType) ? row.type : "documentation") as DocType,
|
||||
section: (VALID_SECTIONS.includes(row.section as DocSection)
|
||||
? row.section
|
||||
: "docs") as DocSection,
|
||||
type: row.type || "documentation",
|
||||
section: row.section || "docs",
|
||||
status: (row.status === "draft" ? "draft" : "published") as DocStatus,
|
||||
author: row.author,
|
||||
tags: row.tags,
|
||||
path: row.path,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
author: row.author || "",
|
||||
tags: row.tags || [],
|
||||
path: row.path || `${row.slug}.md`,
|
||||
createdAt: row.createdAt ? row.createdAt.toISOString() : new Date().toISOString(),
|
||||
updatedAt: row.updatedAt ? row.updatedAt.toISOString() : new Date().toISOString(),
|
||||
extraFields: extra,
|
||||
// Spread dynamic extra fields (CTF: event, challenge, category, difficulty, points, etc.)
|
||||
...extra,
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
export type DocSection = "docs" | "writeups" | "research" | "notes";
|
||||
export type DocType = "documentation" | "writeup" | "research" | "note";
|
||||
export type DocStatus = "published" | "draft";
|
||||
export type DocSection = string;
|
||||
export type DocType = string;
|
||||
export type DocStatus = "published" | "draft" | string;
|
||||
|
||||
export interface SectionInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
desc: string;
|
||||
docCount: number;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface DocumentMeta {
|
||||
id: string; // slug
|
||||
@@ -18,10 +27,11 @@ export interface DocumentMeta {
|
||||
// difficulty, points). Content creators add arbitrary key-value pairs.
|
||||
// Values can be strings, numbers, booleans, arrays, or objects.
|
||||
extraFields?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Document extends DocumentMeta {
|
||||
body: string; // raw markdown (read from disk or stored)
|
||||
body: string; // markdown body
|
||||
}
|
||||
|
||||
export interface SearchHit {
|
||||
@@ -29,3 +39,4 @@ export interface SearchHit {
|
||||
rank: number;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user