chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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)" : ""}`
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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)" : ""}`
|
||||
);
|
||||
@@ -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.`);
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user