feat(mcpedia): Phase 1 MVP — monorepo, Core, Web UI, MCP server, Postgres FTS

- bun workspaces + Turborepo monorepo (apps/web, apps/mcp; packages/*, scripts)
- @mcpedia/core single business-logic layer (Document/Content/Search services)
- @mcpedia/db Drizzle schema: documents + weighted tsvector (GIN) for FTS
- @mcpedia/parser frontmatter, @mcpedia/search Postgres FTS (ts_rank+ts_headline)
- Next.js 16 Web UI (home/doc SSG, search dynamic) + react-markdown render
- MCP server (stdio) with 4 tools + in-memory smoke test
- scripts/indexer walks content/ -> upserts into Postgres
- 4 seed docs; README + PHASES status
This commit is contained in:
asepharyana
2026-08-19 17:40:14 +07:00
commit ec3a2867d4
60 changed files with 3723 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { CONTENT_ROOT } from "@mcpedia/config";
/** List all markdown/mdx content files relative to CONTENT_ROOT. */
export function listContentFiles(): string[] {
const out: string[] = [];
const walk = (dir: string) => {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) walk(full);
else if (/\.mdx?$/.test(entry)) out.push(relative(CONTENT_ROOT, full));
}
};
walk(CONTENT_ROOT);
return out;
}
/** Read a content file's raw text by relative path. */
export function readContentFile(relPath: string): string {
return readFileSync(join(CONTENT_ROOT, relPath), "utf8");
}
+58
View File
@@ -0,0 +1,58 @@
import { and, eq, sql } from "drizzle-orm";
import { db } from "@mcpedia/db";
import { documents } from "@mcpedia/db/schema";
import { CONTENT_ROOT } from "@mcpedia/config";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type {
Document,
DocumentMeta,
} from "@mcpedia/types";
import { readContentFile } from "./content.service";
import { toMeta } from "./row-map";
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));
const rows = await db
.select()
.from(documents)
.where(and(...where))
.orderBy(documents.updatedAt);
return rows.map(toMeta);
}
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.
const abs = join(CONTENT_ROOT, row.path);
const body = existsSync(abs) ? readFileSync(abs, "utf8") : row.body;
return { ...toMeta(row), body };
}
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`, `,
)}]::text[]`;
const related = await db
.select()
.from(documents)
.where(and(eq(documents.status, "published"), sql`${documents.tags} && ${arrLit}`))
.limit(limit + 1);
return related.filter((d) => d.slug !== slug).map(toMeta).slice(0, limit);
}
export { readContentFile };
+13
View File
@@ -0,0 +1,13 @@
export * from "./content.service";
export * from "./document.service";
export * from "./search.service";
export { toMeta } from "./row-map";
export type {
DocSection,
DocStatus,
DocType,
Document,
DocumentMeta,
SearchHit,
} from "@mcpedia/types";
+3
View File
@@ -0,0 +1,3 @@
// Single source of truth for row→domain mapping lives in @mcpedia/search.
export { toMeta } from "@mcpedia/search";
export type { DocumentRow } from "@mcpedia/db/schema";
+1
View File
@@ -0,0 +1 @@
export { keywordSearch, toTsQuery } from "@mcpedia/search";