merge: integrate database documents, preserve all proposals and keep CI main-only
This commit is contained in:
@@ -11,7 +11,8 @@
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"typecheck": "NEXT_TELEMETRY_DISABLED=1 next typegen && tsc --noEmit",
|
||||
"test:auth": "node scripts/test-auth.mjs",
|
||||
"test:research": "node scripts/test-research.mjs"
|
||||
"test:research": "node scripts/test-research.mjs",
|
||||
"test:docs": "node scripts/test-docs.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.4.3",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
totp,
|
||||
isLoginRedirect,
|
||||
} from "../src/lib/auth";
|
||||
import { readDocument, saveDocument } from "../src/lib/docs";
|
||||
import { consumeCode, initializeStore } from "../src/lib/auth-store";
|
||||
|
||||
async function main() {
|
||||
@@ -132,11 +133,6 @@ async function main() {
|
||||
join(__dirname, ".next/BUILD_ID"),
|
||||
"utf8",
|
||||
).trim();
|
||||
const routes = Object.keys(
|
||||
JSON.parse(
|
||||
readFileSync(join(__dirname, ".next/prerender-manifest.json"), "utf8"),
|
||||
).routes,
|
||||
) as string[];
|
||||
for (const path of [
|
||||
"/",
|
||||
"/docs/00-endor-concepts-draft",
|
||||
@@ -160,7 +156,7 @@ async function main() {
|
||||
path,
|
||||
);
|
||||
}
|
||||
for (const path of ["/api/private", "/api/unknown", "/api/search", "/api/research/ai-companies"])
|
||||
for (const path of ["/api/private", "/api/unknown", "/api/search", "/api/documents/source?slug=endor/endor-purpose-draft", "/api/research/ai-companies"])
|
||||
assert.equal((await request(path)).status, 401, path);
|
||||
assert.equal(
|
||||
(
|
||||
@@ -209,6 +205,33 @@ async function main() {
|
||||
assert.equal(destination.searchParams.get("from"), "legacy");
|
||||
assert.ok(legacy.headers.get("cache-control")?.includes("no-store"));
|
||||
}
|
||||
const routes = ["/", ...searchIndex.map((doc: { slug: string }) => `/docs/${doc.slug}`)];
|
||||
const raw = await request("/api/documents/source?slug=endor/endor-purpose-draft", { headers });
|
||||
assert.equal(raw.status, 200);
|
||||
assert.ok((await raw.text()).startsWith("# Endor"));
|
||||
if (process.env.DOCUMENT_SMOKE_TEST) {
|
||||
const sourceSlug = "ci-live-document";
|
||||
const previous = readDocument(sourceSlug);
|
||||
if (command === "exercise") {
|
||||
const first = saveDocument({ slug: "endor/ci-live-document", sourceSlug,
|
||||
title: "Runtime document", category: "Endor", collection: "Endor 文档", group: "CI",
|
||||
description: "Isolated smoke test", draft: true, featured: false,
|
||||
content: "# Runtime document\n\nInitial body" }, previous?.revision ?? 0);
|
||||
assert.equal((await request(`/docs/${first.slug}`, { headers })).status, 200);
|
||||
saveDocument({ ...first, slug: "endor/ci-live-document-renamed", content: "# Runtime document\n\nUpdated without rebuilding" }, first.revision);
|
||||
}
|
||||
const stored = readDocument(sourceSlug);
|
||||
assert.ok(stored, "Smoke document must exist after creation or restart");
|
||||
assert.ok(stored.content.includes("Updated without rebuilding"), "DB content survives restart");
|
||||
const live = await request(`/docs/${stored.slug}`, { headers });
|
||||
assert.equal(live.status, 200);
|
||||
assert.ok((await live.text()).includes("Updated without rebuilding"));
|
||||
const alias = await request("/docs/endor/ci-live-document?live=1", { headers });
|
||||
assert.equal(alias.status, 308);
|
||||
assert.ok(alias.headers.get("location")?.includes("ci-live-document-renamed?live=1"));
|
||||
const updatedIndex = await (await request("/api/search", { headers })).json();
|
||||
assert.ok(updatedIndex.some((doc: { searchText: string }) => doc.searchText.includes("Updated without rebuilding")));
|
||||
}
|
||||
let home = "";
|
||||
let purpose = "";
|
||||
for (const route of routes) {
|
||||
|
||||
@@ -7,3 +7,8 @@ await build({
|
||||
format: "cjs",
|
||||
outfile: ".next/standalone/apps/docs/auth-cli.cjs",
|
||||
});
|
||||
|
||||
await build({
|
||||
entryPoints: ["scripts/docs-cli.ts"], bundle: true, platform: "node", target: "node22",
|
||||
format: "cjs", outfile: ".next/standalone/apps/docs/docs-cli.cjs",
|
||||
});
|
||||
|
||||
@@ -23,19 +23,6 @@ for (const name of await files(path.join(root, "apps/docs/public"))) {
|
||||
throw new Error(`Original raster exposed in public: ${path.relative(root, name)}. Move it to tools/figures/assets and run images.`);
|
||||
}
|
||||
}
|
||||
for (const name of await files(path.join(root, "content/docs"))) {
|
||||
if (!name.endsWith(".md")) continue;
|
||||
const content = await readFile(name, "utf8");
|
||||
const images = [
|
||||
...content.matchAll(/!\[[^\]]*\]\(<?([^\s)>]+)/g),
|
||||
...content.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)/gi),
|
||||
];
|
||||
for (const [, source] of images) {
|
||||
if (/\.(?:png|jpe?g|tiff?|bmp)(?:[?#]|$)/i.test(source)) {
|
||||
throw new Error(`${path.relative(root, name)} embeds an original raster: ${source}. Use the compressed WebP.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(output, { recursive: true });
|
||||
for (const source of await files(sources)) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { openSiteDatabase } from "../src/lib/site-database";
|
||||
import { allDocuments, readDocument, saveDocument, importDocuments, documentHistory } from "../src/lib/docs";
|
||||
|
||||
process.umask(0o077);
|
||||
const [command, argument] = process.argv.slice(2);
|
||||
try {
|
||||
switch (command) {
|
||||
case "backup": {
|
||||
if (!argument) throw Error("Provide a private backup filename");
|
||||
const db = openSiteDatabase();
|
||||
try { db.prepare("VACUUM INTO ?").run(argument); } finally { db.close(); }
|
||||
console.log("Full site database backup created; existing files are never overwritten"); break;
|
||||
}
|
||||
case "list":
|
||||
console.log(JSON.stringify(allDocuments().map(({ content: _, ...doc }) => { void _; return doc; }), null, 2)); break;
|
||||
case "get": {
|
||||
const doc = readDocument(argument ?? "");
|
||||
if (!doc) throw Error("Document not found");
|
||||
console.log(JSON.stringify(doc, null, 2)); break;
|
||||
}
|
||||
case "save": {
|
||||
const { revision, ...input } = JSON.parse(readFileSync(0, "utf8"));
|
||||
const doc = saveDocument(input, revision);
|
||||
console.log(JSON.stringify({ slug: doc.slug, revision: doc.revision })); break;
|
||||
}
|
||||
case "history":
|
||||
console.log(JSON.stringify(documentHistory(argument ?? ""), null, 2)); break;
|
||||
case "import": {
|
||||
const bundle = JSON.parse(readFileSync(0, "utf8"));
|
||||
console.log(JSON.stringify({ imported: importDocuments(bundle, argument === "--if-empty") })); break;
|
||||
}
|
||||
case "export": {
|
||||
if (!argument) throw Error("Provide a private export filename");
|
||||
writeFileSync(argument, JSON.stringify({ format: "tabularium-documents-v1", documents: allDocuments() }),
|
||||
{ flag: "wx", mode: 0o600 });
|
||||
console.log("Document export created; existing files are never overwritten"); break;
|
||||
}
|
||||
default: throw Error("Usage: docs backup <private-file> | list | get <slug> | save < input.json | history <sourceSlug> | import [--if-empty] < export.json | export <private-file>");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : "Document operation failed");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { build } from "esbuild";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), "tabularium-docs-tests-"));
|
||||
try {
|
||||
const file = join(dir, "docs.test.cjs");
|
||||
await build({ entryPoints: ["tests/docs.test.ts"], bundle: true,
|
||||
platform: "node", target: "node22", format: "cjs", outfile: file });
|
||||
const result = spawnSync(process.execPath, ["--test", file], { stdio: "inherit" });
|
||||
process.exitCode = result.status ?? 1;
|
||||
} finally { await rm(dir, { recursive: true, force: true }); }
|
||||
+135
-38
@@ -1,50 +1,147 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import catalog from "../../../../content/catalog.json";
|
||||
import { randomUUID, createHash } from "node:crypto";
|
||||
import { openSiteDatabase } from "./site-database";
|
||||
|
||||
const directory = path.resolve(process.cwd(), "../../content/docs");
|
||||
const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
|
||||
type Metadata = { slug?: string; category?: string; collection?: string; group?: string; description?: string; draft?: boolean; featured?: boolean };
|
||||
export type DocLink = {
|
||||
slug: string; sourceSlug: string; category: string; title: string; searchText?: string;
|
||||
group: string; collection: string; description: string; draft: boolean; featured: boolean;
|
||||
revision: number;
|
||||
};
|
||||
export type Doc = DocLink & { content: string };
|
||||
export type DocInput = Omit<Doc, "revision" | "searchText">;
|
||||
export type DocumentExport = { format: "tabularium-documents-v1"; documents: Doc[] };
|
||||
export const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
|
||||
|
||||
function database() {
|
||||
const db = openSiteDatabase();
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, source_slug TEXT NOT NULL UNIQUE,
|
||||
revision INTEGER NOT NULL, payload TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS document_aliases (
|
||||
alias TEXT PRIMARY KEY, document_id TEXT NOT NULL REFERENCES documents(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS document_revisions (
|
||||
document_id TEXT NOT NULL REFERENCES documents(id), revision INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(document_id, revision)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS document_imports (
|
||||
digest TEXT PRIMARY KEY, document_count INTEGER NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function validateDocument(input: DocInput): void {
|
||||
if (!input || typeof input !== "object") throw Error("Invalid document");
|
||||
for (const key of ["slug", "sourceSlug"] as const)
|
||||
if (typeof input[key] !== "string" || !validSlug.test(input[key])) throw Error(`Invalid ${key}`);
|
||||
if (input.slug.split("/").some((part) => /^\d+-/.test(part))) throw Error("Use a semantic public slug");
|
||||
for (const key of ["title", "category", "collection", "group"] as const)
|
||||
if (typeof input[key] !== "string" || !input[key].trim()) throw Error(`Missing ${key}`);
|
||||
if (typeof input.content !== "string" || typeof input.description !== "string" ||
|
||||
typeof input.draft !== "boolean" || typeof input.featured !== "boolean") throw Error("Invalid document fields");
|
||||
if (input.content.length > 2_000_000) throw Error("Document exceeds content limit");
|
||||
const images = [
|
||||
...input.content.matchAll(/!\[[^\]]*\]\(<?([^\s)>]+)/g),
|
||||
...input.content.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)/gi),
|
||||
];
|
||||
if (images.some(([, source]) => /\.(?:png|jpe?g|tiff?|bmp)(?:[?#]|$)/i.test(source)))
|
||||
throw Error("Use compressed WebP images instead of original rasters");
|
||||
}
|
||||
|
||||
function write(db: ReturnType<typeof database>, input: DocInput, expectedRevision: number): Doc {
|
||||
validateDocument(input);
|
||||
const existing = db.prepare("SELECT id, revision FROM documents WHERE source_slug=?")
|
||||
.get(input.sourceSlug) as { id: string; revision: number } | undefined;
|
||||
if ((existing?.revision ?? 0) !== expectedRevision) throw Error("Document revision conflict");
|
||||
const id = existing?.id ?? randomUUID();
|
||||
const doc: Doc = { slug: input.slug, sourceSlug: input.sourceSlug, category: input.category,
|
||||
title: input.title, group: input.group, collection: input.collection, description: input.description,
|
||||
draft: input.draft, featured: input.featured, content: input.content, revision: expectedRevision + 1 };
|
||||
const payload = JSON.stringify(doc);
|
||||
// Aliases remain reserved after renaming; collision rolls back the whole write.
|
||||
db.prepare(`INSERT INTO documents VALUES (?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET
|
||||
slug=excluded.slug, revision=excluded.revision, payload=excluded.payload`)
|
||||
.run(id, doc.slug, doc.sourceSlug, doc.revision, payload);
|
||||
for (const alias of new Set([doc.slug, doc.sourceSlug])) {
|
||||
const prior = db.prepare("SELECT document_id FROM document_aliases WHERE alias=?")
|
||||
.get(alias) as { document_id: string } | undefined;
|
||||
if (prior && prior.document_id !== id) throw Error(`Document alias collision: ${alias}`);
|
||||
db.prepare("INSERT OR IGNORE INTO document_aliases VALUES (?,?)").run(alias, id);
|
||||
}
|
||||
db.prepare("INSERT INTO document_revisions VALUES (?,?,?,?)")
|
||||
.run(id, doc.revision, payload, new Date().toISOString());
|
||||
return doc;
|
||||
}
|
||||
|
||||
export function saveDocument(input: DocInput, expectedRevision: number): Doc {
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw Error("Provide expected revision");
|
||||
const db = database();
|
||||
try {
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
try { const doc = write(db, input, expectedRevision); db.exec("COMMIT"); return doc; }
|
||||
catch (error) { db.exec("ROLLBACK"); throw error; }
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
export function readDocument(slug: string): Doc | null {
|
||||
if (!validSlug.test(slug)) return null;
|
||||
const db = database();
|
||||
try {
|
||||
const row = db.prepare(`SELECT d.payload FROM documents d JOIN document_aliases a
|
||||
ON a.document_id=d.id WHERE a.alias=?`).get(slug) as { payload: string } | undefined;
|
||||
return row ? JSON.parse(row.payload) as Doc : null;
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
export function allDocuments(): Doc[] {
|
||||
const db = database();
|
||||
try {
|
||||
return (db.prepare("SELECT payload FROM documents ORDER BY source_slug").all() as { payload: string }[])
|
||||
.map((row) => JSON.parse(row.payload) as Doc);
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
export async function readDoc(slug: string): Promise<Doc> {
|
||||
if (!validSlug.test(slug)) throw new Error("Invalid document slug");
|
||||
const sourceSlug = Object.entries(catalog.documents).find(([, metadata]) => metadata.slug === slug)?.[0] ?? slug;
|
||||
const content = await fs.readFile(path.join(directory, `${sourceSlug}.md`), "utf8");
|
||||
const title = content.match(/^# (.+)$/m)?.[1]?.trim() ?? slug;
|
||||
const collection = catalog.collections.find((c) => !c.path || sourceSlug.startsWith(`${c.path}/`))!;
|
||||
const metadata: Metadata = (catalog.documents as Record<string, Metadata>)[sourceSlug] ?? {};
|
||||
if (!metadata.slug || !metadata.category || !validSlug.test(metadata.slug) || metadata.slug.split("/").some((part) => /^\d+-/.test(part))) {
|
||||
throw new Error(`Register a semantic slug and category in content/catalog.json: ${sourceSlug}`);
|
||||
}
|
||||
return { slug: metadata.slug, sourceSlug, category: metadata.category, title, content, searchText: content,
|
||||
group: metadata.group ?? collection.title, collection: metadata.collection ?? collection.title,
|
||||
description: metadata.description ?? collection.description,
|
||||
draft: metadata.draft ?? false, featured: metadata.featured ?? false,
|
||||
};
|
||||
const doc = readDocument(slug);
|
||||
if (!doc) throw Error("Document not found");
|
||||
return doc;
|
||||
}
|
||||
|
||||
export async function listDocs(): Promise<DocLink[]> {
|
||||
async function walk(folder: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(path.join(directory, folder), { withFileTypes: true });
|
||||
const files = await Promise.all(entries.map(async (entry) => {
|
||||
const relative = folder ? `${folder}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) return walk(relative);
|
||||
if (entry.isFile() && relative.endsWith(".md") && validSlug.test(relative.slice(0, -3)))
|
||||
return [relative.slice(0, -3)];
|
||||
return [];
|
||||
}));
|
||||
return files.flat();
|
||||
}
|
||||
const docs = await Promise.all((await walk("")).sort().map(async (slug) => {
|
||||
const { content: _, searchText: __, ...doc } = await readDoc(slug);
|
||||
void _; void __;
|
||||
return doc;
|
||||
}));
|
||||
if (new Set(docs.map((doc) => doc.slug)).size !== docs.length) throw new Error("Duplicate public document slug");
|
||||
return docs;
|
||||
return allDocuments().map(({ content: _, ...doc }) => { void _; return doc; });
|
||||
}
|
||||
|
||||
// Explicit, atomic bootstrap only. Runtime reads never import repository data.
|
||||
export function importDocuments(bundle: DocumentExport, onlyIfEmpty = false): number {
|
||||
if (bundle?.format !== "tabularium-documents-v1" || !Array.isArray(bundle.documents) || !bundle.documents.length)
|
||||
throw Error("Invalid or empty document export");
|
||||
bundle.documents.forEach(validateDocument);
|
||||
const digest = createHash("sha256").update(JSON.stringify(bundle)).digest("hex");
|
||||
const db = database();
|
||||
try {
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const imported = db.prepare("SELECT digest FROM document_imports WHERE digest=?").get(digest);
|
||||
if (imported) { db.exec("COMMIT"); return 0; }
|
||||
const count = (db.prepare("SELECT COUNT(*) AS n FROM documents").get() as { n: number }).n;
|
||||
if (count) {
|
||||
if (!onlyIfEmpty) throw Error("Bootstrap requires an empty document database");
|
||||
db.exec("COMMIT"); return 0;
|
||||
}
|
||||
for (const doc of bundle.documents) write(db, doc, 0);
|
||||
db.prepare("INSERT INTO document_imports VALUES (?,?,?)").run(digest, bundle.documents.length, new Date().toISOString());
|
||||
db.exec("COMMIT"); return bundle.documents.length;
|
||||
} catch (error) { db.exec("ROLLBACK"); throw error; }
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
export function documentHistory(sourceSlug: string): Doc[] {
|
||||
const db = database();
|
||||
try {
|
||||
return (db.prepare(`SELECT r.payload FROM document_revisions r JOIN documents d
|
||||
ON r.document_id=d.id WHERE d.source_slug=? ORDER BY r.revision`).all(sourceSlug) as { payload: string }[])
|
||||
.map((row) => JSON.parse(row.payload) as Doc);
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { readDocument } from "@/lib/docs";
|
||||
|
||||
// The proxy protects Markdown just like the rendered document and search index.
|
||||
export default function handler(request: NextApiRequest, response: NextApiResponse) {
|
||||
response.setHeader("Cache-Control", "private, no-store");
|
||||
if (request.method !== "GET") {
|
||||
response.setHeader("Allow", "GET");
|
||||
return response.status(405).end();
|
||||
}
|
||||
const slug = request.query.slug;
|
||||
if (typeof slug !== "string") return response.status(400).json({ error: "缺少文档地址" });
|
||||
try {
|
||||
const doc = readDocument(slug);
|
||||
if (!doc) return response.status(404).end();
|
||||
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
return response.status(200).send(doc.content);
|
||||
} catch {
|
||||
return response.status(503).json({ error: "文档数据库暂时不可用" });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { listDocs, readDoc } from "@/lib/docs";
|
||||
import { allDocuments } from "@/lib/docs";
|
||||
|
||||
// The proxy authenticates this endpoint, including index content.
|
||||
export default async function handler(request: NextApiRequest, response: NextApiResponse) {
|
||||
@@ -8,9 +8,10 @@ export default async function handler(request: NextApiRequest, response: NextApi
|
||||
response.setHeader("Allow", "GET");
|
||||
return response.status(405).end();
|
||||
}
|
||||
const docs = await listDocs();
|
||||
const index = await Promise.all(docs.map(async (doc) => ({
|
||||
...doc, searchText: (await readDoc(doc.slug)).content,
|
||||
})));
|
||||
response.status(200).json(index);
|
||||
try {
|
||||
const index = allDocuments().map(({ content, ...doc }) => ({ ...doc, searchText: content }));
|
||||
return response.status(200).json(index);
|
||||
} catch {
|
||||
return response.status(503).json({ error: "文档数据库暂时不可用" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type {
|
||||
GetStaticPaths,
|
||||
GetStaticProps,
|
||||
InferGetStaticPropsType,
|
||||
GetServerSideProps,
|
||||
InferGetServerSidePropsType,
|
||||
} from "next";
|
||||
import Head from "next/head";
|
||||
import dynamic from "next/dynamic";
|
||||
@@ -15,7 +14,7 @@ import {
|
||||
} from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { listDocs, readDoc, type Doc, type DocLink } from "@/lib/docs";
|
||||
import { listDocs, readDocument, type Doc, type DocLink } from "@/lib/docs";
|
||||
import { Shell, Navigation } from "@/components/Shell";
|
||||
import { ArchitectureFigure } from "@/components/ArchitectureFigure";
|
||||
import { FigureGallery } from "@/components/FigureGallery";
|
||||
@@ -26,16 +25,17 @@ import { headingsFor } from "@/lib/navigation";
|
||||
|
||||
type Props = { doc: Doc; docs: DocLink[] };
|
||||
const AICompanyResearch = dynamic(() => import("@/components/AICompanyResearch"));
|
||||
export const getStaticPaths: GetStaticPaths = async () => ({
|
||||
paths: (await listDocs()).map(({ slug }) => ({ params: { slug: slug.split("/") } })),
|
||||
fallback: false,
|
||||
});
|
||||
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
|
||||
export const getServerSideProps: GetServerSideProps<Props> = async ({ params, res, resolvedUrl }) => {
|
||||
res.setHeader("Cache-Control", "private, no-store");
|
||||
const slug = Array.isArray(params?.slug) ? params.slug.join("/") : params?.slug;
|
||||
if (typeof slug !== "string") return { notFound: true };
|
||||
const docs = await listDocs();
|
||||
if (!docs.some((d) => d.slug === slug)) return { notFound: true };
|
||||
return { props: { doc: await readDoc(slug), docs } };
|
||||
const doc = readDocument(slug);
|
||||
if (!doc) return { notFound: true };
|
||||
if (slug !== doc.slug) {
|
||||
const query = resolvedUrl.includes("?") ? resolvedUrl.slice(resolvedUrl.indexOf("?")) : "";
|
||||
return { redirect: { destination: `/docs/${doc.slug}${query}`, permanent: true } };
|
||||
}
|
||||
return { props: { doc, docs: await listDocs() } };
|
||||
};
|
||||
function textOf(value: ReactNode): string {
|
||||
return Children.toArray(value)
|
||||
@@ -49,7 +49,7 @@ function textOf(value: ReactNode): string {
|
||||
export default function DocPage({
|
||||
doc,
|
||||
docs,
|
||||
}: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const headings = headingsFor(doc.content);
|
||||
const [active, setActive] = useState("");
|
||||
|
||||
@@ -231,9 +231,9 @@ export default function DocPage({
|
||||
<div className="article-end">
|
||||
|
||||
<a
|
||||
href={`https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs/src/branch/main/content/docs/${doc.sourceSlug}.md`}
|
||||
href={`/api/documents/source?slug=${encodeURIComponent(doc.slug)}`}
|
||||
>
|
||||
查看文档源码 ↗
|
||||
查看 Markdown 原文 ↗
|
||||
</a>
|
||||
</div>
|
||||
<nav className="pagination" aria-label="相邻文档">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GetStaticProps, InferGetStaticPropsType } from "next";
|
||||
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
@@ -7,11 +7,12 @@ import { Shell, Navigation } from "@/components/Shell";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { collectionEntries, collectionIcon, collectionLabel, collectionTone } from "@/lib/collections";
|
||||
|
||||
export const getStaticProps: GetStaticProps<{ docs: DocLink[] }> = async () => ({
|
||||
props: { docs: await listDocs() },
|
||||
});
|
||||
export const getServerSideProps: GetServerSideProps<{ docs: DocLink[] }> = async ({ res }) => {
|
||||
res.setHeader("Cache-Control", "private, no-store");
|
||||
return { props: { docs: await listDocs() } };
|
||||
};
|
||||
const PAGE_SIZE = 8;
|
||||
export default function Home({ docs }: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
export default function Home({ docs }: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const [collection, setCollection] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { authConfig, SESSION_COOKIE, validSession } from "./lib/auth";
|
||||
import catalog from "../../../content/catalog.json";
|
||||
|
||||
const legacyRoutes = new Map(Object.entries(catalog.documents).map(([source, metadata]) => [
|
||||
`/docs/${source}`, `/docs/${metadata.slug}`,
|
||||
]));
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const path = request.nextUrl.pathname;
|
||||
const headers = {
|
||||
@@ -54,13 +48,6 @@ export function proxy(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Keep legacy URLs behind authentication and out of static generation.
|
||||
const destination = legacyRoutes.get(path);
|
||||
if (destination && ["GET", "HEAD"].includes(request.method)) {
|
||||
const target = request.nextUrl.clone();
|
||||
target.pathname = destination;
|
||||
return NextResponse.redirect(target, { status: 308, headers });
|
||||
}
|
||||
return NextResponse.next({ headers });
|
||||
}
|
||||
if (path.startsWith("/api/") || !["GET", "HEAD"].includes(request.method)) {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { allDocuments, documentHistory, importDocuments, readDocument, saveDocument,
|
||||
type DocInput, type DocumentExport } from "../src/lib/docs";
|
||||
|
||||
const input: DocInput = { slug: "endor/example", sourceSlug: "00-example", title: "Example",
|
||||
category: "Endor", collection: "Endor 文档", group: "讨论", description: "Example description",
|
||||
draft: true, featured: false, content: "# Example\n\nOriginal body" };
|
||||
|
||||
function isolated(run: (directory: string) => void) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "tabularium-docs-"));
|
||||
const previous = process.env.AUTH_CONFIG_FILE;
|
||||
process.env.AUTH_CONFIG_FILE = join(directory, "config.json");
|
||||
try { run(directory); } finally {
|
||||
if (previous === undefined) delete process.env.AUTH_CONFIG_FILE;
|
||||
else process.env.AUTH_CONFIG_FILE = previous;
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function bundle(documents: DocInput[]): DocumentExport {
|
||||
return { format: "tabularium-documents-v1", documents: documents.map((doc) => ({ ...doc, revision: 1 })) };
|
||||
}
|
||||
|
||||
test("writes and metadata persist across independent connections; no repository seed", () => isolated(() => {
|
||||
assert.deepEqual(allDocuments(), []);
|
||||
const saved = saveDocument(input, 0);
|
||||
assert.deepEqual(readDocument(input.slug), saved);
|
||||
assert.deepEqual(readDocument(input.sourceSlug), saved);
|
||||
const changed = saveDocument({ ...input, content: "# Revised", collection: "新合集", group: "新目录" }, 1);
|
||||
assert.equal(changed.revision, 2);
|
||||
assert.equal(allDocuments()[0].collection, "新合集");
|
||||
assert.deepEqual(documentHistory(input.sourceSlug).map((doc) => doc.content), [input.content, "# Revised"]);
|
||||
}));
|
||||
|
||||
test("optimistic concurrency prevents lost updates and requires explicit revision", () => isolated(() => {
|
||||
saveDocument(input, 0);
|
||||
assert.throws(() => saveDocument({ ...input, content: "Lost" }, 0), /conflict/);
|
||||
assert.throws(() => saveDocument(input, NaN), /expected revision/);
|
||||
assert.equal(readDocument(input.slug)?.content, input.content);
|
||||
assert.equal(documentHistory(input.sourceSlug).length, 1);
|
||||
}));
|
||||
|
||||
test("renames preserve aliases and alias collisions roll back document and revision", () => isolated(() => {
|
||||
saveDocument(input, 0);
|
||||
saveDocument({ ...input, slug: "endor/renamed" }, 1);
|
||||
assert.equal(readDocument(input.slug)?.slug, "endor/renamed");
|
||||
assert.throws(() => saveDocument({ ...input, sourceSlug: "another", slug: input.slug }, 0), /collision/);
|
||||
assert.equal(allDocuments().length, 1);
|
||||
assert.equal(readDocument("another"), null);
|
||||
assert.equal(documentHistory(input.sourceSlug).length, 2);
|
||||
}));
|
||||
|
||||
test("bootstrap is atomic and repeat imports never replace DB edits", () => isolated(() => {
|
||||
const snapshot = bundle([input]);
|
||||
assert.equal(importDocuments(snapshot), 1);
|
||||
saveDocument({ ...input, content: "Written directly to DB" }, 1);
|
||||
assert.equal(importDocuments(snapshot), 0);
|
||||
assert.equal(importDocuments(bundle([{ ...input, content: "Stale release" }]), true), 0);
|
||||
assert.throws(() => importDocuments(bundle([{ ...input, content: "Other" }])), /empty/);
|
||||
assert.equal(readDocument(input.slug)?.content, "Written directly to DB");
|
||||
}));
|
||||
|
||||
test("colliding bootstrap leaves no partial rows and can be retried", () => isolated(() => {
|
||||
assert.throws(() => importDocuments(bundle([input, { ...input, sourceSlug: "other" }])));
|
||||
assert.deepEqual(allDocuments(), []);
|
||||
assert.equal(importDocuments(bundle([input])), 1);
|
||||
}));
|
||||
|
||||
test("migration preserves unrelated authentication and research tables", () => isolated((directory) => {
|
||||
const db = new DatabaseSync(join(directory, "login-state.sqlite"));
|
||||
db.exec("CREATE TABLE login_state(value TEXT); INSERT INTO login_state VALUES ('preserved'); CREATE TABLE research_datasets(value TEXT); INSERT INTO research_datasets VALUES ('research');");
|
||||
db.close();
|
||||
importDocuments(bundle([input]));
|
||||
const reopened = new DatabaseSync(join(directory, "login-state.sqlite"));
|
||||
assert.equal(reopened.prepare("SELECT value FROM login_state").get()?.value, "preserved");
|
||||
assert.equal(reopened.prepare("SELECT value FROM research_datasets").get()?.value, "research");
|
||||
assert.deepEqual(reopened.prepare("PRAGMA foreign_key_check").all(), []);
|
||||
reopened.close();
|
||||
}));
|
||||
|
||||
test("invalid content cannot enter the database", () => isolated(() => {
|
||||
for (const invalid of [ { ...input, slug: "../private" }, { ...input, slug: "endor/01-example" },
|
||||
{ ...input, content: "" }, { ...input, category: "" } ])
|
||||
assert.throws(() => saveDocument(invalid, 0));
|
||||
assert.deepEqual(allDocuments(), []);
|
||||
assert.equal(readDocument("../private"), null);
|
||||
}));
|
||||
|
||||
test("missing database configuration fails closed", () => {
|
||||
const previous = process.env.AUTH_CONFIG_FILE;
|
||||
delete process.env.AUTH_CONFIG_FILE;
|
||||
try { assert.throws(() => allDocuments(), /not configured/); }
|
||||
finally { if (previous !== undefined) process.env.AUTH_CONFIG_FILE = previous; }
|
||||
});
|
||||
Reference in New Issue
Block a user