chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:12 +08:00
commit d8dcd5f6d1
8604 changed files with 2479390 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env node
/**
* add-frontmatter.mjs — one-shot helper that ensures every documentation
* file under docs/<sub>/*.md and docs/README.md has a YAML frontmatter
* header with `title`, `version`, and `lastUpdated`.
*
* Idempotent: docs that already have a `---` block at the top are skipped
* (the existing frontmatter is preserved as-is). Files without a leading
* `# Title` heading fall back to the basename humanized as a title.
*
* Excludes: docs/i18n/, docs/screenshots/, docs/superpowers/,
* docs/diagrams/exported/. Subfolder READMEs are included.
*
* Usage: node scripts/docs/add-frontmatter.mjs [--version X.Y.Z] [--date YYYY-MM-DD]
*/
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.resolve(__dirname, "..", "..");
const DOCS_DIR = path.join(ROOT, "docs");
const EXCLUDE_PREFIXES = [
"docs/i18n/",
"docs/screenshots/",
"docs/superpowers/",
"docs/diagrams/exported/",
];
const args = process.argv.slice(2);
let version = "3.8.0";
let lastUpdated = "2026-05-13";
for (let i = 0; i < args.length; i += 1) {
if (args[i] === "--version" && args[i + 1]) {
version = args[i + 1];
i += 1;
} else if (args[i] === "--date" && args[i + 1]) {
lastUpdated = args[i + 1];
i += 1;
}
}
async function walk(dir, files = []) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(full, files);
} else if (entry.isFile() && entry.name.endsWith(".md")) {
files.push(full);
}
}
return files;
}
function isExcluded(rel) {
return EXCLUDE_PREFIXES.some((p) => rel === p || rel.startsWith(p));
}
function hasFrontmatter(content) {
return /^---\r?\n/.test(content);
}
function extractTopHeading(content) {
const lines = content.replace(/^/, "").split(/\r?\n/);
for (const line of lines) {
const m = line.match(/^#\s+(.+?)\s*$/);
if (m) return m[1].trim();
// Allow blank/HTML-comment lines before the first heading
if (line.trim() === "" || /^<!--/.test(line.trim())) continue;
// Anything else (e.g. badge image, paragraph) — no usable heading
return null;
}
return null;
}
function humanizeBasename(filePath) {
const base = path.basename(filePath, ".md");
if (base.toLowerCase() === "readme") {
const parent = path.basename(path.dirname(filePath));
if (parent && parent !== "." && parent !== "docs") {
const cap = parent.charAt(0).toUpperCase() + parent.slice(1);
return `${cap} Docs`;
}
return "Documentation";
}
return base
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function buildFrontmatter(title) {
// Quote title with double quotes; escape backslashes first, then double quotes.
const safe = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return [
`---`,
`title: "${safe}"`,
`version: ${version}`,
`lastUpdated: ${lastUpdated}`,
`---`,
``,
].join("\n");
}
async function main() {
const allFiles = await walk(DOCS_DIR);
const targets = allFiles
.map((abs) => ({ abs, rel: path.relative(ROOT, abs).replace(/\\/g, "/") }))
.filter(({ rel }) => !isExcluded(rel));
let added = 0;
let skipped = 0;
let noHeading = [];
for (const { abs, rel } of targets) {
const content = await fs.readFile(abs, "utf8");
if (hasFrontmatter(content)) {
skipped += 1;
continue;
}
let title = extractTopHeading(content);
if (!title) {
title = humanizeBasename(abs);
noHeading.push(rel);
}
const fm = buildFrontmatter(title);
const next = `${fm}\n${content.replace(/^/, "")}`;
await fs.writeFile(abs, next, "utf8");
added += 1;
console.log(`[add-frontmatter] added → ${rel}`);
}
console.log(`[add-frontmatter] done — added=${added} skipped=${skipped} total=${targets.length}`);
if (noHeading.length > 0) {
console.log(
`[add-frontmatter] fallback title used (no leading H1) for: ${noHeading.join(", ")}`
);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env node
// One-shot: FASE 3 helper, safe to delete after merge.
//
// Rewrites doc-file references after the docs/ flat -> subfolder restructure.
//
// Modes:
// --internal Rewrite relative links inside docs/<subfolder>/*.md to point at
// the new subfolder paths (e.g. ./AUTO-COMBO.md -> ../routing/AUTO-COMBO.md).
// --external Rewrite absolute-style `docs/<DOC>.md` references in files
// outside docs/ (README, CLAUDE.md, .agents, .claude, scripts, src,
// tests, etc.) to `docs/<subfolder>/<DOC>.md`.
//
// Usage:
// node scripts/docs/fix-internal-links.mjs --internal
// node scripts/docs/fix-internal-links.mjs --external
// node scripts/docs/fix-internal-links.mjs --internal --external --dry
//
// Notes:
// - Idempotent: rerunning does not double-rewrite (it only matches the old shape).
// - openapi.yaml lives under docs/reference/ — also handled.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
const DOCS = path.join(ROOT, "docs");
const args = new Set(process.argv.slice(2));
const RUN_INTERNAL = args.has("--internal");
const RUN_EXTERNAL = args.has("--external");
const DRY = args.has("--dry");
if (!RUN_INTERNAL && !RUN_EXTERNAL) {
console.error("Pass --internal and/or --external (and optionally --dry).");
process.exit(2);
}
// ----------------------------------------------------------------------
// Mapping: stem -> subfolder
// ----------------------------------------------------------------------
const DOC_TO_SUBFOLDER = {
// architecture
"ARCHITECTURE.md": "architecture",
"CODEBASE_DOCUMENTATION.md": "architecture",
"REPOSITORY_MAP.md": "architecture",
"AUTHZ_GUIDE.md": "architecture",
"RESILIENCE_GUIDE.md": "architecture",
// guides
"SETUP_GUIDE.md": "guides",
"USER_GUIDE.md": "guides",
"DOCKER_GUIDE.md": "guides",
"ELECTRON_GUIDE.md": "guides",
"TERMUX_GUIDE.md": "guides",
"PWA_GUIDE.md": "guides",
"TROUBLESHOOTING.md": "guides",
"UNINSTALL.md": "guides",
"I18N.md": "guides",
"FEATURES.md": "guides",
// reference
"API_REFERENCE.md": "reference",
"PROVIDER_REFERENCE.md": "reference",
"openapi.yaml": "reference",
"ENVIRONMENT.md": "reference",
"CLI-TOOLS.md": "reference",
"FREE_TIERS.md": "reference",
// frameworks
"MCP-SERVER.md": "frameworks",
"A2A-SERVER.md": "frameworks",
"AGENT_PROTOCOLS_GUIDE.md": "frameworks",
"CLOUD_AGENT.md": "frameworks",
"SKILLS.md": "frameworks",
"MEMORY.md": "frameworks",
"WEBHOOKS.md": "frameworks",
"EVALS.md": "frameworks",
// routing
"AUTO-COMBO.md": "routing",
"REASONING_REPLAY.md": "routing",
// security
"GUARDRAILS.md": "security",
"COMPLIANCE.md": "security",
"STEALTH_GUIDE.md": "security",
// compression
"COMPRESSION_GUIDE.md": "compression",
"COMPRESSION_ENGINES.md": "compression",
"COMPRESSION_RULES_FORMAT.md": "compression",
"COMPRESSION_LANGUAGE_PACKS.md": "compression",
"RTK_COMPRESSION.md": "compression",
// ops
"RELEASE_CHECKLIST.md": "ops",
"COVERAGE_PLAN.md": "ops",
"FLY_IO_DEPLOYMENT_GUIDE.md": "ops",
"VM_DEPLOYMENT_GUIDE.md": "ops",
"PROXY_GUIDE.md": "ops",
"TUNNELS_GUIDE.md": "ops",
};
// Build alternation regex (longest-first) of file basenames we know about.
// Escape regex metacharacters (including backslash) defensively, even though
// the source list is internal — keeps CodeQL happy and future-proofs against
// names like "FOO\BAR.md".
const RE_META = /[\\^$.*+?()[\]{}|]/g;
const FILES_ALT = Object.keys(DOC_TO_SUBFOLDER)
.sort((a, b) => b.length - a.length)
.map((s) => s.replace(RE_META, "\\$&"))
.join("|");
// ----------------------------------------------------------------------
// Internal rewriter (within docs/**)
// ----------------------------------------------------------------------
function listDocFiles() {
const out = [];
// walk subfolders we created
for (const sub of new Set(Object.values(DOC_TO_SUBFOLDER))) {
const dir = path.join(DOCS, sub);
if (!fs.existsSync(dir)) continue;
for (const f of fs.readdirSync(dir)) {
if (!f.endsWith(".md") && !f.endsWith(".yaml")) continue;
out.push(path.join(dir, f));
}
}
// also rewrite the new README and diagrams README
out.push(path.join(DOCS, "README.md"));
return out;
}
function relativeFromTo(fromAbsFile, targetSub, targetBasename) {
const fromDir = path.dirname(fromAbsFile);
const toAbs = path.join(DOCS, targetSub, targetBasename);
let rel = path.relative(fromDir, toAbs);
// posix-style
rel = rel.split(path.sep).join("/");
if (!rel.startsWith(".")) rel = "./" + rel;
return rel;
}
function rewriteInternal(filePath) {
const src = fs.readFileSync(filePath, "utf8");
let out = src;
// Pattern A: relative refs like ./FOO.md, ../FOO.md, or bare FOO.md inside (... ).
// We match `]( <optional ./ or ../+> <basename> )` and `]( <basename> )`.
// Captures: prefix (=./ or ../ chains, may be empty), basename.
const reA = new RegExp(`\\]\\(\\s*((?:\\.{1,2}/)*)(${FILES_ALT})((?:#[^\\s)]+)?)\\s*\\)`, "g");
out = out.replace(reA, (full, prefix, basename, anchor) => {
const subFolder = DOC_TO_SUBFOLDER[basename];
if (!subFolder) return full;
const newRel = relativeFromTo(filePath, subFolder, basename);
return `](${newRel}${anchor || ""})`;
});
// Pattern B: absolute-style `docs/FOO.md` inside markdown links — convert
// to `docs/<sub>/FOO.md`. We avoid double-rewriting if a subfolder is
// already present.
const reB = new RegExp(`docs/(${FILES_ALT})((?:#[^\\s)\"']+)?)`, "g");
out = out.replace(reB, (full, basename, anchor) => {
const subFolder = DOC_TO_SUBFOLDER[basename];
if (!subFolder) return full;
// If preceded by `<sub>/` already, skip — but the regex won't capture that
// because it only matches `docs/<basename>`. So this is safe.
return `docs/${subFolder}/${basename}${anchor || ""}`;
});
if (out !== src) {
if (!DRY) fs.writeFileSync(filePath, out, "utf8");
return true;
}
return false;
}
// ----------------------------------------------------------------------
// External rewriter (files outside docs/)
// ----------------------------------------------------------------------
function listExternalFiles() {
// We rewrite a curated set of paths to avoid wandering into vendor / build dirs.
const candidates = [];
// 1) Root-level documentation files
for (const f of fs.readdirSync(ROOT)) {
const full = path.join(ROOT, f);
if (!fs.statSync(full).isFile()) continue;
if (/\.(md|txt)$/i.test(f)) candidates.push(full);
}
// 2) Specific directories we know contain doc references
const dirs = [
".agents",
".claude",
".github",
"bin",
"electron",
"open-sse",
"scripts",
"src",
"tests",
"vscode-extension",
// i18n mirrors — root-level locale files (llm.txt, CHANGELOG.md, etc.) reference
// the root /docs/ paths and must stay in sync after restructure.
"docs/i18n",
];
for (const d of dirs) {
const full = path.join(ROOT, d);
if (!fs.existsSync(full)) continue;
walk(full, candidates);
}
return candidates;
}
function walk(dir, out) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules" || entry.name === ".next") continue;
if (entry.name.startsWith(".git")) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full, out);
} else if (entry.isFile()) {
// accept text-y file types
if (/\.(md|txt|ts|tsx|mjs|cjs|js|json|yaml|yml|sh)$/i.test(entry.name)) {
out.push(full);
}
}
}
}
function rewriteExternal(filePath) {
const src = fs.readFileSync(filePath, "utf8");
let out = src;
// Rewrite any `docs/FOO.md` (only the bare-basename form, not already
// pointing into a subfolder) to `docs/<sub>/FOO.md`.
// Word-boundary lookbehind/lookahead-ish: use (?<![\w/-]) so we don't touch
// already-prefixed `docs/architecture/FOO.md`.
const re = new RegExp(`(?<![A-Za-z0-9_./-])docs/(${FILES_ALT})((?:#[^\\s)\"'\`]+)?)`, "g");
out = out.replace(re, (full, basename, anchor) => {
const subFolder = DOC_TO_SUBFOLDER[basename];
if (!subFolder) return full;
return `docs/${subFolder}/${basename}${anchor || ""}`;
});
if (out !== src) {
if (!DRY) fs.writeFileSync(filePath, out, "utf8");
return true;
}
return false;
}
// ----------------------------------------------------------------------
// Run
// ----------------------------------------------------------------------
let internalChanged = 0;
let externalChanged = 0;
let internalScanned = 0;
let externalScanned = 0;
if (RUN_INTERNAL) {
const files = listDocFiles();
for (const f of files) {
internalScanned++;
if (rewriteInternal(f)) internalChanged++;
}
console.log(
`[internal] scanned=${internalScanned} changed=${internalChanged}${DRY ? " (dry-run)" : ""}`
);
}
if (RUN_EXTERNAL) {
const files = listExternalFiles();
for (const f of files) {
externalScanned++;
if (rewriteExternal(f)) externalChanged++;
}
console.log(
`[external] scanned=${externalScanned} changed=${externalChanged}${DRY ? " (dry-run)" : ""}`
);
}
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* gen-openapi-module.mjs — build helper that reads docs/openapi.yaml,
* flattens the path/method matrix, and emits
* src/app/docs/lib/openapi.generated.ts so the Api Explorer client can
* iterate endpoints without parsing YAML at runtime.
*
* Runtime guarantees:
* - No `any`. Everything is typed via an explicit `OpenApiEndpoint`.
* - Endpoints are pre-sorted by (path, method) for stable output.
* - Internal management endpoints (those under /api/ but NOT /api/v1) are
* filtered out by default so the Api Explorer focuses on the public
* OpenAI-compatible surface. Override with --include-management.
*
* Wired into `prebuild:docs` so `next build` always sees a fresh module.
*/
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import * as yaml from "js-yaml";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.resolve(__dirname, "..", "..");
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
const OUT_PATH = path.join(ROOT, "src", "app", "docs", "lib", "openapi.generated.ts");
const HTTP_METHODS = ["get", "post", "put", "delete", "patch", "options", "head"];
const args = process.argv.slice(2);
const includeManagement = args.includes("--include-management");
function summarizeEndpoint(rawPath, method, op) {
const tags = Array.isArray(op?.tags) && op.tags.length > 0 ? op.tags : ["Other"];
return {
path: rawPath,
method: method.toUpperCase(),
summary: typeof op?.summary === "string" ? op.summary : "",
description: typeof op?.description === "string" ? op.description : "",
tag: typeof tags[0] === "string" ? tags[0] : "Other",
tags,
requiresAuth: Array.isArray(op?.security) && op.security.length > 0,
hasRequestBody: Boolean(op?.requestBody),
};
}
function isPublicV1(rawPath) {
// Anything starting with /api/v1 is the OpenAI-compatible surface; everything
// else under /api/* is internal management. Routes that don't start with /api
// (rare) are kept because they are typically root-level surfaces.
return rawPath.startsWith("/api/v1") || !rawPath.startsWith("/api/");
}
function quote(value) {
if (value === undefined || value === null) return "undefined";
return JSON.stringify(value);
}
function tsArray(values) {
if (!values || values.length === 0) return "[]";
return `[${values.map((v) => quote(v)).join(", ")}]`;
}
function renderEndpoint(ep) {
return ` {
path: ${quote(ep.path)},
method: ${quote(ep.method)},
summary: ${quote(ep.summary)},
description: ${quote(ep.description)},
tag: ${quote(ep.tag)},
tags: ${tsArray(ep.tags)},
requiresAuth: ${ep.requiresAuth ? "true" : "false"},
hasRequestBody: ${ep.hasRequestBody ? "true" : "false"},
}`;
}
async function main() {
const yamlText = await fs.readFile(OPENAPI_PATH, "utf8");
const spec = yaml.load(yamlText);
if (!spec || typeof spec !== "object" || !spec.paths || typeof spec.paths !== "object") {
throw new Error("openapi.yaml has no `paths` map");
}
const version = spec.info && typeof spec.info.version === "string" ? spec.info.version : "0.0.0";
const title =
spec.info && typeof spec.info.title === "string" ? spec.info.title : "OmniRoute API";
const endpoints = [];
for (const [rawPath, pathItem] of Object.entries(spec.paths)) {
if (!pathItem || typeof pathItem !== "object") continue;
if (!includeManagement && !isPublicV1(rawPath)) continue;
for (const method of HTTP_METHODS) {
const op = pathItem[method];
if (!op) continue;
endpoints.push(summarizeEndpoint(rawPath, method, op));
}
}
endpoints.sort((a, b) => {
if (a.path !== b.path) return a.path.localeCompare(b.path);
return a.method.localeCompare(b.method);
});
const totalManagement = Object.entries(spec.paths).filter(([p]) => !isPublicV1(p)).length;
const header = `// AUTO-GENERATED by scripts/docs/gen-openapi-module.mjs — DO NOT EDIT MANUALLY
// Regenerate with: node scripts/docs/gen-openapi-module.mjs
//
// Source of truth: docs/openapi.yaml
//
// The Api Explorer consumes \`OPENAPI_ENDPOINTS\`; the spec metadata
// (\`OPENAPI_VERSION\`, \`OPENAPI_TITLE\`) is surfaced in the page header.
`;
const body = `
export interface OpenApiEndpoint {
/** Path template — may contain \`{param}\` placeholders. */
path: string;
/** HTTP method in upper case (GET / POST / PUT / DELETE / PATCH / ...). */
method: string;
/** Short one-line summary from the spec. */
summary: string;
/** Long-form description (markdown is allowed). */
description: string;
/** Primary tag — used for sidebar grouping in the Api Explorer. */
tag: string;
/** All tags declared on the operation. */
tags: string[];
/** \`true\` when the operation declares a non-empty \`security\` array. */
requiresAuth: boolean;
/** \`true\` when the operation declares a \`requestBody\`. */
hasRequestBody: boolean;
}
export const OPENAPI_VERSION = ${quote(version)};
export const OPENAPI_TITLE = ${quote(title)};
export const OPENAPI_ENDPOINTS: OpenApiEndpoint[] = [
${endpoints.map(renderEndpoint).join(",\n")}${endpoints.length > 0 ? "," : ""}
];
export const OPENAPI_TAGS: string[] = Array.from(
new Set(OPENAPI_ENDPOINTS.map((endpoint) => endpoint.tag))
).sort();
`;
await fs.mkdir(path.dirname(OUT_PATH), { recursive: true });
await fs.writeFile(OUT_PATH, `${header}${body}`, "utf8");
console.log(
`[gen-openapi-module] wrote ${path.relative(ROOT, OUT_PATH)} (${endpoints.length} endpoints, v${version}, skipped ${totalManagement} management paths)`
);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env node
// Generates docs/reference/PROVIDER_REFERENCE.md from src/shared/constants/providers.ts.
// Run: node --import tsx scripts/docs/gen-provider-reference.ts
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
FREE_PROVIDERS,
OAUTH_PROVIDERS,
WEB_COOKIE_PROVIDERS,
APIKEY_PROVIDERS,
LOCAL_PROVIDERS,
SEARCH_PROVIDERS,
AUDIO_ONLY_PROVIDERS,
UPSTREAM_PROXY_PROVIDERS,
CLOUD_AGENT_PROVIDERS,
SYSTEM_PROVIDERS,
IMAGE_ONLY_PROVIDER_IDS,
AGGREGATOR_PROVIDER_IDS,
ENTERPRISE_CLOUD_PROVIDER_IDS,
VIDEO_PROVIDER_IDS,
EMBEDDING_RERANK_PROVIDER_IDS,
SELF_HOSTED_CHAT_PROVIDER_IDS,
} from "../../src/shared/constants/providers.ts";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
const OUT_FILE = path.join(ROOT, "docs", "reference", "PROVIDER_REFERENCE.md");
type ProviderRecord = {
id: string;
alias?: string | undefined;
name: string;
icon?: string;
color?: string;
textIcon?: string;
website?: string;
authHint?: string;
freeNote?: string;
hasFree?: boolean;
deprecated?: boolean;
deprecationReason?: string;
[k: string]: unknown;
};
function asRecords(map: Record<string, ProviderRecord>): ProviderRecord[] {
return Object.values(map).map((p) => ({ ...p }));
}
function escapeCell(value: string | undefined): string {
if (!value) return "—";
// Escape backslash first so the subsequent escapes don't double-escape it.
return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\n/g, " ");
}
function row(p: ProviderRecord, category: string): string {
const alias = p.alias ? `\`${p.alias}\`` : "—";
const hint = p.deprecated
? `⚠️ **DEPRECATED.** ${escapeCell(p.deprecationReason)}`
: escapeCell(p.authHint || p.freeNote);
const link = p.website ? `[link](${p.website})` : "—";
return `| \`${p.id}\` | ${alias} | ${escapeCell(p.name)} | ${category} | ${link} | ${hint} |`;
}
function categoryTags(id: string): string[] {
const tags: string[] = [];
if (IMAGE_ONLY_PROVIDER_IDS.has(id)) tags.push("image");
if (VIDEO_PROVIDER_IDS.has(id)) tags.push("video");
if (AGGREGATOR_PROVIDER_IDS.has(id)) tags.push("aggregator");
if (ENTERPRISE_CLOUD_PROVIDER_IDS.has(id)) tags.push("enterprise");
if (EMBEDDING_RERANK_PROVIDER_IDS.has(id)) tags.push("embed/rerank");
if (SELF_HOSTED_CHAT_PROVIDER_IDS.has(id)) tags.push("self-hosted");
return tags;
}
function sortById(rows: ProviderRecord[]): ProviderRecord[] {
return [...rows].sort((a, b) => a.id.localeCompare(b.id));
}
function buildSection(title: string, rows: ProviderRecord[], category: string): string {
if (rows.length === 0) return "";
const lines: string[] = [];
lines.push(`## ${title} (${rows.length})\n`);
lines.push("| ID | Alias | Name | Tags | Website | Notes |");
lines.push("|----|-------|------|------|---------|-------|");
for (const p of sortById(rows)) {
const tags = [category, ...categoryTags(p.id)].join(", ");
lines.push(row(p, tags));
}
lines.push("");
return lines.join("\n");
}
function buildHeader(total: number): string {
const date = new Date().toISOString().slice(0, 10);
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")) as {
version?: string;
};
return [
"---",
'title: "Provider Reference"',
`version: ${pkg.version || "unknown"}`,
`lastUpdated: ${date}`,
"---",
"",
"# Provider Reference",
"",
`> **Auto-generated** from \`src/shared/constants/providers.ts\` — do not edit by hand.`,
`> Regenerate with: \`npm run gen:provider-reference\``,
`> **Last generated:** ${date}`,
"",
`Total providers: **${total}**. See category breakdown below.`,
"",
"## Categories",
"",
"- **Free** — free tier with API key (configured via dashboard)",
"- **OAuth** — sign-in flow handled by OmniRoute, no API key needed",
"- **Web cookie** — wraps the provider's web app via cookie auth",
"- **API key** — paid provider configured via API key (free credits may apply)",
"- **Local** — runs on the user's machine (Ollama, LM Studio, vLLM, etc.)",
"- **Search** — web search providers",
"- **Audio** — audio-only providers (TTS/STT)",
"- **Upstream proxy** — providers that proxy to other providers",
"- **Cloud agent** — long-running coding agents (Codex Cloud, Devin, Jules)",
"- **System** — OmniRoute-internal providers (loopback, etc.)",
"",
"Additional tags: `image`, `video`, `aggregator`, `enterprise`, `embed/rerank`, `self-hosted`.",
"",
"Use the dashboard at `/dashboard/providers` to enable, configure, and test each provider.",
"",
"---",
"",
].join("\n");
}
function main() {
const free = asRecords(FREE_PROVIDERS);
const oauth = asRecords(OAUTH_PROVIDERS);
const webCookie = asRecords(WEB_COOKIE_PROVIDERS);
const apiKey = asRecords(APIKEY_PROVIDERS);
const local = asRecords(LOCAL_PROVIDERS);
const search = asRecords(SEARCH_PROVIDERS);
const audio = asRecords(AUDIO_ONLY_PROVIDERS);
const upstreamProxy = asRecords(UPSTREAM_PROXY_PROVIDERS);
const cloudAgent = asRecords(CLOUD_AGENT_PROVIDERS);
const system = asRecords(SYSTEM_PROVIDERS);
const allIds = new Set<string>([
...free.map((p) => p.id),
...oauth.map((p) => p.id),
...webCookie.map((p) => p.id),
...apiKey.map((p) => p.id),
...local.map((p) => p.id),
...search.map((p) => p.id),
...audio.map((p) => p.id),
...upstreamProxy.map((p) => p.id),
...cloudAgent.map((p) => p.id),
...system.map((p) => p.id),
]);
const sections = [
buildSection("Free Tier (OAuth-first or no-key)", free, "Free"),
buildSection("OAuth Providers", oauth, "OAuth"),
buildSection("Web Cookie Providers", webCookie, "Web cookie"),
buildSection("API Key Providers (paid / paid-with-free-credits)", apiKey, "API key"),
buildSection("Local Providers", local, "Local"),
buildSection("Search Providers", search, "Search"),
buildSection("Audio-only Providers", audio, "Audio"),
buildSection("Upstream Proxy Providers", upstreamProxy, "Upstream proxy"),
buildSection("Cloud Agent Providers", cloudAgent, "Cloud agent"),
buildSection("System Providers", system, "System"),
];
const footer = [
"## Sources of truth",
"",
"- Catalog: [`src/shared/constants/providers.ts`](../../src/shared/constants/providers.ts)",
"- Registry (per-model details): [`open-sse/config/providerRegistry.ts`](../../open-sse/config/providerRegistry.ts)",
"- Executors: [`open-sse/executors/`](../../open-sse/executors/) (31 files)",
"- Translators: [`open-sse/translator/`](../../open-sse/translator/)",
"",
"## See Also",
"",
"- [FREE_TIERS.md](./FREE_TIERS.md) — curated free-tier guide",
"- [USER_GUIDE.md](../guides/USER_GUIDE.md) — provider setup walkthrough",
"- [ARCHITECTURE.md](../architecture/ARCHITECTURE.md) — overall architecture",
"",
].join("\n");
const content = buildHeader(allIds.size) + sections.join("\n") + "\n" + footer;
fs.writeFileSync(OUT_FILE, content);
console.log(`✓ Wrote ${OUT_FILE}`);
console.log(` Providers: ${allIds.size} unique IDs`);
console.log(
` Sections: free=${free.length}, oauth=${oauth.length}, web=${webCookie.length}, ` +
`apikey=${apiKey.length}, local=${local.length}, search=${search.length}, ` +
`audio=${audio.length}, proxy=${upstreamProxy.length}, cloud=${cloudAgent.length}, system=${system.length}`
);
}
main();
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env node
// One-shot: FASE 3 helper, safe to delete after merge.
//
// Moves existing i18n mirror docs from `docs/i18n/<lang>/docs/X.md` into the
// matching subfolder `docs/i18n/<lang>/docs/<sub>/X.md`, mirroring the new
// docs/ layout. Uses `git mv` to preserve history.
//
// Usage:
// node scripts/docs/move-i18n-mirrors.mjs [--dry]
//
// Notes:
// - Skips files that don't appear in DOC_TO_SUBFOLDER (e.g., the legacy
// `cloudflare-zero-trust-guide.md` or `features/` subfolder — those will be
// handled in FASE 5 when translations are regenerated).
// - Idempotent: if the target already lives under a subfolder, the entry is
// skipped.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
const I18N_DIR = path.join(ROOT, "docs", "i18n");
const DRY = process.argv.includes("--dry");
const DOC_TO_SUBFOLDER = {
// architecture
"ARCHITECTURE.md": "architecture",
"CODEBASE_DOCUMENTATION.md": "architecture",
"REPOSITORY_MAP.md": "architecture",
"AUTHZ_GUIDE.md": "architecture",
"RESILIENCE_GUIDE.md": "architecture",
// guides
"SETUP_GUIDE.md": "guides",
"USER_GUIDE.md": "guides",
"DOCKER_GUIDE.md": "guides",
"ELECTRON_GUIDE.md": "guides",
"TERMUX_GUIDE.md": "guides",
"PWA_GUIDE.md": "guides",
"TROUBLESHOOTING.md": "guides",
"UNINSTALL.md": "guides",
"I18N.md": "guides",
"FEATURES.md": "guides",
// reference
"API_REFERENCE.md": "reference",
"PROVIDER_REFERENCE.md": "reference",
"openapi.yaml": "reference",
"ENVIRONMENT.md": "reference",
"CLI-TOOLS.md": "reference",
"FREE_TIERS.md": "reference",
// frameworks
"MCP-SERVER.md": "frameworks",
"A2A-SERVER.md": "frameworks",
"AGENT_PROTOCOLS_GUIDE.md": "frameworks",
"CLOUD_AGENT.md": "frameworks",
"SKILLS.md": "frameworks",
"MEMORY.md": "frameworks",
"WEBHOOKS.md": "frameworks",
"EVALS.md": "frameworks",
// routing
"AUTO-COMBO.md": "routing",
"REASONING_REPLAY.md": "routing",
// security
"GUARDRAILS.md": "security",
"COMPLIANCE.md": "security",
"STEALTH_GUIDE.md": "security",
// compression
"COMPRESSION_GUIDE.md": "compression",
"COMPRESSION_ENGINES.md": "compression",
"COMPRESSION_RULES_FORMAT.md": "compression",
"COMPRESSION_LANGUAGE_PACKS.md": "compression",
"RTK_COMPRESSION.md": "compression",
// ops
"RELEASE_CHECKLIST.md": "ops",
"COVERAGE_PLAN.md": "ops",
"FLY_IO_DEPLOYMENT_GUIDE.md": "ops",
"VM_DEPLOYMENT_GUIDE.md": "ops",
"PROXY_GUIDE.md": "ops",
"TUNNELS_GUIDE.md": "ops",
};
let moved = 0;
let skipped = 0;
const seenLocales = [];
for (const locale of fs.readdirSync(I18N_DIR)) {
const localeDir = path.join(I18N_DIR, locale);
const stat = fs.statSync(localeDir);
if (!stat.isDirectory()) continue;
const docsDir = path.join(localeDir, "docs");
if (!fs.existsSync(docsDir)) continue;
seenLocales.push(locale);
for (const fname of fs.readdirSync(docsDir)) {
const sub = DOC_TO_SUBFOLDER[fname];
if (!sub) continue; // not in our mapping (e.g. features/, cloudflare-zero-trust-guide.md)
const src = path.join(docsDir, fname);
if (!fs.statSync(src).isFile()) continue;
const subDir = path.join(docsDir, sub);
const dst = path.join(subDir, fname);
if (fs.existsSync(dst)) {
skipped++;
continue;
}
if (DRY) {
console.log(`would move: ${path.relative(ROOT, src)} -> ${path.relative(ROOT, dst)}`);
moved++;
continue;
}
if (!fs.existsSync(subDir)) fs.mkdirSync(subDir, { recursive: true });
const relSrc = path.relative(ROOT, src);
const relDst = path.relative(ROOT, dst);
try {
execFileSync("git", ["mv", "-k", "--", relSrc, relDst], {
cwd: ROOT,
stdio: "pipe",
});
moved++;
} catch {
// fallback: copy + delete; emulate `|| true` for the rm by ignoring its failure
fs.renameSync(src, dst);
try {
execFileSync("git", ["rm", "--cached", "--", relSrc], { cwd: ROOT, stdio: "pipe" });
} catch {
// file may not be tracked yet — safe to ignore
}
execFileSync("git", ["add", "--", relDst], { cwd: ROOT, stdio: "pipe" });
moved++;
}
}
}
console.log(
`[i18n-mirrors] locales=${seenLocales.length} moved=${moved} skipped=${skipped}${DRY ? " (dry-run)" : ""}`
);
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env node
/**
* Render every Mermaid source in docs/diagrams/*.mmd into docs/diagrams/exported/*.svg
*
* Usage:
* npm run docs:render-diagrams
*
* Requirements:
* - @mermaid-js/mermaid-cli (`mmdc`) on PATH or installed globally.
* `npm install -g @mermaid-js/mermaid-cli` if missing.
*
* Notes:
* - Puppeteer needs `--no-sandbox` on Ubuntu 23.10+ / WSL. A temp config file
* is written automatically.
* - Each diagram is rendered with `--backgroundColor white` so the SVG works
* against both light and dark themes.
* - The script exits non-zero on first failure so CI / pre-commit hooks can
* gate on it.
*/
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..", "..");
const srcDir = resolve(repoRoot, "docs", "diagrams");
const outDir = resolve(srcDir, "exported");
if (!existsSync(srcDir)) {
console.error(`[render-diagrams] missing source dir: ${srcDir}`);
process.exit(1);
}
if (!existsSync(outDir)) {
mkdirSync(outDir, { recursive: true });
}
// Puppeteer needs --no-sandbox on many Linux distros (Ubuntu 23.10+, WSL).
const puppeteerConfigPath = join(tmpdir(), "omniroute-mmdc-puppeteer.json");
writeFileSync(
puppeteerConfigPath,
JSON.stringify({ args: ["--no-sandbox", "--disable-setuid-sandbox"] }, null, 2)
);
const sources = readdirSync(srcDir)
.filter((f) => f.endsWith(".mmd"))
.sort();
if (sources.length === 0) {
console.error(`[render-diagrams] no .mmd files in ${srcDir}`);
process.exit(1);
}
console.log(`[render-diagrams] rendering ${sources.length} diagram(s)`);
let failures = 0;
for (const src of sources) {
const input = join(srcDir, src);
const output = join(outDir, src.replace(/\.mmd$/, ".svg"));
console.log(` - ${src} -> ${output.replace(repoRoot + "/", "")}`);
const result = spawnSync(
"mmdc",
[
"-i",
input,
"-o",
output,
"--backgroundColor",
"white",
"--puppeteerConfigFile",
puppeteerConfigPath,
],
{ stdio: "inherit" }
);
if (result.status !== 0) {
console.error(` [FAIL] ${src} (exit ${result.status})`);
failures += 1;
}
}
if (failures > 0) {
console.error(`[render-diagrams] ${failures} failure(s)`);
process.exit(1);
}
console.log(`[render-diagrams] all ${sources.length} diagram(s) rendered.`);
+330
View File
@@ -0,0 +1,330 @@
#!/usr/bin/env node
// scripts/docs/sync-wiki.mjs
// Full GitHub wiki content + cover-count sync.
//
// WHY: the wiki has no generator and historically drifts (it sat at "212+ providers /
// 14 strategies / 37 MCP tools" while code was at 226 / 15 / 87, and new docs like
// SUPPLY_CHAIN never appeared). This closes the loop: content + counts, automated per
// release by .github/workflows/wiki-sync.yml.
//
// DESIGN — update-in-place, never duplicates:
// 1. The wiki page names are hand-curated and NOT deterministically reproducible
// (e.g. "API-Reference" vs "Fly-io-Deployment-Guide"). So we iterate the EXISTING
// wiki pages and fuzzy-match each to a docs/ source by normalized key
// (lowercase, strip non-alphanumeric). When a source exists we rewrite that exact
// page → zero risk of creating a parallel/duplicate page.
// 2. A curated allowlist (NEW_PAGE_EXCLUDE) keeps internal docs (audit reports, plans,
// the docs index) off the public wiki; every other unmatched docs page is ADDED
// with a deterministic acronym-aware name.
// 3. Hand-curated pages with no docs source (Home, _Sidebar, Header, _Footer,
// Languages) are left untouched — except the four cover counts on Home.md.
// 4. EN by default. Localized mirrors (<locale>Page) are pure update-in-place and
// only touched with --include-i18n (the i18n source lags and is validated
// separately).
//
// Content transform: strip the YAML frontmatter, prepend the wiki language banner.
//
// Usage:
// node scripts/docs/sync-wiki.mjs --wiki-dir <path> # write
// node scripts/docs/sync-wiki.mjs --wiki-dir <path> --dry-run # report only
// node scripts/docs/sync-wiki.mjs --wiki-dir <path> --check # exit 1 on drift
// node scripts/docs/sync-wiki.mjs --wiki-dir <path> --include-i18n # also localized
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
// U+2010 HYPHEN separates the locale prefix in localized wiki page names.
const LOCALE_SEP = "";
export const WIKI_BANNER = "> 🌍 [View in other languages](Languages)\n\n\n";
// Docs that must never become public wiki pages (internal reports/plans/index).
export const NEW_PAGE_EXCLUDE = new Set([
"README", // docs index, not a page
"DOCUMENTATION_AUDIT_REPORT",
"DOCUMENTATION_OVERHAUL_PLAN",
"E2E_DASHBOARD_SHAKEDOWN_v3.8.0",
"SUBMIT_PR",
"fix-opencode-context",
"plugins", // docs/dev/plugins.md — internal dev note
"SOCKET_DEV_FINDINGS",
]);
// Acronyms kept upper-case when minting a NEW page name (existing pages keep their
// curated name via fuzzy match, so this only affects brand-new pages).
const ACRONYMS = new Set([
"api",
"mcp",
"a2a",
"acp",
"cli",
"sse",
"i18n",
"pii",
"oauth",
"vm",
"ai",
"llm",
"sdk",
"ide",
"ui",
"ux",
"tls",
"mitm",
"ws",
"cors",
"jwt",
"db",
"vps",
]);
/** Normalized matching key: lowercase, drop extension + every non-alphanumeric char. */
export function normKey(s) {
return s
.toLowerCase()
.replace(/\.md$/, "")
.replace(/[^a-z0-9]/g, "");
}
/** Deterministic wiki page name for a brand-new page (acronym-aware Title-Case-dashed). */
export function toWikiName(basename) {
return basename
.replace(/\.md$/, "")
.split(/[_\-\s]+/)
.filter(Boolean)
.map((t) =>
ACRONYMS.has(t.toLowerCase())
? t.toUpperCase()
: t[0].toUpperCase() + t.slice(1).toLowerCase()
)
.join("-");
}
/** Strip YAML frontmatter and prepend the wiki language banner. Pure; exported for tests. */
export function toWikiContent(docMarkdown) {
const body = docMarkdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").replace(/^\s+/, "");
return WIKI_BANNER + body.replace(/\s*$/, "") + "\n";
}
function read(rel) {
const p = path.join(ROOT, rel);
return fs.existsSync(p) ? fs.readFileSync(p, "utf8") : "";
}
// ---- cover-page counts (source of truth) ----
function providerCount() {
const m = read("docs/reference/PROVIDER_REFERENCE.md").match(/Total providers:\s*\*\*(\d+)\*\*/);
return m ? Number(m[1]) : null;
}
function strategyCount() {
const m = read("src/shared/constants/routingStrategies.ts").match(
/ROUTING_STRATEGY_VALUES\s*=\s*\[([^\]]*)\]/
);
return m ? (m[1].match(/"[^"]+"/g) || []).length : null;
}
function localeCount() {
try {
const c = JSON.parse(read("config/i18n.json"));
return Array.isArray(c.locales) ? c.locales.length : null;
} catch {
return null;
}
}
function mcpToolCount() {
// Prefer a literal; the constant is computed at runtime so best-effort only.
const m = read("open-sse/mcp-server/server.ts").match(/TOTAL_MCP_TOOL_COUNT\s*=\s*(\d+)\b/);
return m ? Number(m[1]) : null;
}
export function readCounts() {
return {
providers: providerCount(),
strategies: strategyCount(),
mcpTools: mcpToolCount(),
locales: localeCount(),
};
}
/** Apply cover-page count substitutions to Home.md text. Pure; exported for tests. */
export function syncHomeCounts(home, counts) {
let out = home;
if (counts.providers) {
out = out
.replace(
/Connect every AI tool to \d+ providers/g,
`Connect every AI tool to ${counts.providers} providers`
)
.replace(/\*\*\d+ AI Providers\*\*/g, `**${counts.providers} AI Providers**`)
.replace(/All \d+ supported providers/g, `All ${counts.providers} supported providers`)
.replace(/\b\d+ providers\b/g, `${counts.providers} providers`);
}
if (counts.strategies) {
out = out.replace(
/\*\*\d+ Routing Strategies\*\*/g,
`**${counts.strategies} Routing Strategies**`
);
}
if (counts.mcpTools) {
out = out.replace(/(\|\s*\*\*MCP Server\*\*\s*\|\s*)\d+( tools)/g, `$1${counts.mcpTools}$2`);
}
return out;
}
// ---- docs discovery ----
function walkMarkdown(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walkMarkdown(p, acc);
else if (e.name.endsWith(".md")) acc.push(p);
}
return acc;
}
/** Build normKey → docs absolute path for English docs (docs/ minus docs/i18n). */
function indexEnglishDocs() {
const docsRoot = path.join(ROOT, "docs");
const files = walkMarkdown(docsRoot).filter((f) => !f.includes(`${path.sep}i18n${path.sep}`));
const byKey = new Map();
for (const f of files) {
const base = path.basename(f, ".md");
const k = normKey(base);
// First-writer-wins keeps a deterministic pick for basename collisions.
if (!byKey.has(k)) byKey.set(k, { file: f, base });
}
return byKey;
}
/** Build normKey → docs path for a given locale's i18n tree. */
function indexLocaleDocs(locale) {
const root = path.join(ROOT, "docs", "i18n", locale);
const byKey = new Map();
for (const f of walkMarkdown(root)) {
const k = normKey(path.basename(f, ".md"));
if (!byKey.has(k)) byKey.set(k, f);
}
return byKey;
}
function listWikiPages(wikiDir) {
return fs
.readdirSync(wikiDir)
.filter((n) => n.endsWith(".md"))
.map((n) => n.slice(0, -3));
}
/** Split a wiki page name into { locale, name }. EN pages have locale = null. */
export function parseWikiPage(page) {
const idx = page.indexOf(LOCALE_SEP);
if (idx === -1) return { locale: null, name: page };
return { locale: page.slice(0, idx), name: page.slice(idx + 1) };
}
function main() {
const args = process.argv.slice(2);
const wikiDir = args.includes("--wiki-dir") ? args[args.indexOf("--wiki-dir") + 1] : null;
const dryRun = args.includes("--dry-run");
const check = args.includes("--check");
const includeI18n = args.includes("--include-i18n");
const updateExisting = args.includes("--update-existing");
if (!wikiDir || !fs.existsSync(wikiDir)) {
console.error("usage: sync-wiki.mjs --wiki-dir <path> [--dry-run|--check] [--include-i18n]");
process.exit(2);
}
const enDocs = indexEnglishDocs();
const wikiPages = listWikiPages(wikiDir);
const enWikiKeys = new Set();
const localeIndexes = new Map();
const plan = { update: [], add: [], untouched: [], countsChanged: false };
// 1. Update existing wiki pages from their docs source.
for (const page of wikiPages) {
if (page === "Home") continue; // handled by counts below
const { locale, name } = parseWikiPage(page);
const key = normKey(name);
let srcFile = null;
if (!locale) {
enWikiKeys.add(key);
srcFile = enDocs.get(key)?.file ?? null;
} else if (includeI18n) {
if (!localeIndexes.has(locale)) localeIndexes.set(locale, indexLocaleDocs(locale));
srcFile = localeIndexes.get(locale).get(key) ?? null;
}
if (!srcFile) {
plan.untouched.push(page);
continue;
}
const next = toWikiContent(fs.readFileSync(srcFile, "utf8"));
const cur = fs.readFileSync(path.join(wikiDir, `${page}.md`), "utf8");
if (next !== cur) plan.update.push({ page, srcFile });
}
// 2. Add curated new English pages (unmatched docs, minus the exclude list).
for (const [key, { file, base }] of enDocs) {
if (enWikiKeys.has(key)) continue;
if (NEW_PAGE_EXCLUDE.has(base)) continue;
plan.add.push({ page: toWikiName(base), srcFile: file, base });
}
// 3. Home cover counts.
const counts = readCounts();
const homePath = path.join(wikiDir, "Home.md");
let homeAfter = null;
if (fs.existsSync(homePath)) {
const before = fs.readFileSync(homePath, "utf8");
homeAfter = syncHomeCounts(before, counts);
plan.countsChanged = homeAfter !== before;
}
// ---- report ----
// Updating existing pages is opt-in: several docs SOURCES carry stale counts (e.g.
// ARCHITECTURE.md still says "177 providers / 37 MCP tools" while the wiki cover was
// hand-patched to 226/87). Overwriting from a staler source would REGRESS the wiki, so
// by default we only ADD missing pages and sync Home counts. Pass --update-existing
// once the docs sources are regenerated.
const updates = updateExisting ? plan.update : [];
const total = updates.length + plan.add.length + (plan.countsChanged ? 1 : 0);
console.log(`[wiki-sync] counts: ${JSON.stringify(counts)}`);
console.log(
`[wiki-sync] add: ${plan.add.length} | Home counts: ${plan.countsChanged ? "drift" : "in-sync"} | ` +
`existing-page updates: ${plan.update.length} (${updateExisting ? "ENABLED" : "skipped — needs --update-existing"}) | untouched: ${plan.untouched.length}`
);
if (dryRun || check) {
if (plan.add.length) console.log(` add → ${plan.add.map((a) => a.page).join(", ")}`);
if (plan.update.length)
console.log(
` ${updateExisting ? "update" : "would-update (skipped)"}${plan.update
.map((u) => u.page)
.slice(0, 60)
.join(", ")}${plan.update.length > 60 ? " …" : ""}`
);
if (check) {
if (total > 0) {
console.error(`✗ wiki out of sync (${total} change(s) pending)`);
process.exit(1);
}
console.log("✓ wiki in sync");
}
return;
}
// ---- write ----
for (const { page, srcFile } of [...updates, ...plan.add]) {
fs.writeFileSync(
path.join(wikiDir, `${page}.md`),
toWikiContent(fs.readFileSync(srcFile, "utf8"))
);
}
if (plan.countsChanged && homeAfter != null) fs.writeFileSync(homePath, homeAfter);
console.log(
`[wiki-sync] wrote ${total} page(s) (add: ${plan.add.length}, updates: ${updates.length}, counts: ${plan.countsChanged ? 1 : 0}).`
);
}
const invokedDirectly =
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (invokedDirectly) main();