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:
@@ -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");
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -0,0 +1 @@
|
||||
export { keywordSearch, toTsQuery } from "@mcpedia/search";
|
||||
Reference in New Issue
Block a user