docs: introduce versioned docs and migrate to fuma (#11166)

## Todos for release

- [x] Configure Env in docs project on vercel
- [x] Configure Root Path in the docs project on vercel
- [ ] Remove old CSP https://github.com/zitadel/website/pull/1592

## What we did

This pull request migrates the project documentation from the old
`docs/` directory to the new `apps/docs/` directory, introduces a new
documentation system built with Next.js and Fumadocs, and updates all
relevant references, configuration files, and documentation to reflect
this change. It also adds new configuration and ignore files for the new
documentation app, updates CI and linting to exclude the new docs from
certain checks, and revises the contributing guidelines accordingly.

**Documentation System Migration and New Docs App**

* Migrated all documentation from `docs/` to `apps/docs/`, and updated
all references in `README.md`, `CONTRIBUTING.md`, and other files to
point to the new location.
[[1]](diffhunk://#diff-eca12c0a30e25b4b46522ebf89465a03ba72a03f540796c979137931d8f92055L585-L640)
[[2]](diffhunk://#diff-eca12c0a30e25b4b46522ebf89465a03ba72a03f540796c979137931d8f92055L660-R607)
[[3]](diffhunk://#diff-eca12c0a30e25b4b46522ebf89465a03ba72a03f540796c979137931d8f92055L740-R687)
[[4]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5L2-R3)
[[5]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5L30-R30)
* Added a new Next.js/Fumadocs-based documentation app under
`apps/docs/`, including core app files, layouts, routing, search API,
and a comprehensive `README.md` with development and contribution
instructions.
[[1]](diffhunk://#diff-5a1b07344a2c1b4d3f37b23ff1388b62cd9f57dea3c1cd23d8a0412b7602b132R1-R74)
[[2]](diffhunk://#diff-462b9ad1eabbb7d1c29bb9c36e4931eb180fd7190900e6d2babf8f4d66ad1c28R1-R39)
[[3]](diffhunk://#diff-e16ae25660ded787b10ac35dea96d5ecaacf895dae0afc8a9bd4382dc79a8c87R1-R7)
[apps/docs/app/[[...slug]]/layout.tsxR1-R81](diffhunk://#diff-59e08acde4e805b7aeccef1dcf98f1d71dfc550777e6b9402085cee0e9fa4e0aR1-R81),
[apps/docs/app/[[...slug]]/page.tsxR1-R79](diffhunk://#diff-e5df3f80d0fa01e12d63d81f29c57a9d14e846c78fd3beabb2ef768e38fd9580R1-R79),
[[4]](diffhunk://#diff-389b34918e040cacaa87cd7201ffa462cc2b0b716736f537e3d3c660ac69353bR1-R7)
[[5]](diffhunk://#diff-d3b03416d1c457b19f1c27b26f2db741412df841b875c26ccadc36e4522247f4R1-R29)
[[6]](diffhunk://#diff-c8fb8339570a5305809be7c618e14705fd86390dc278e6ce8ba224a7bc8b0c3cR1-R25)

**Configuration and Tooling Updates**

* Updated `.github/workflows/codeql.yml`, `.golangci.yaml`, and
`.github/dependabot.yml` to properly handle the new docs app: excluded
`apps/docs` from certain checks, added npm dependency updates for the
docs app, and excluded generated content.
[[1]](diffhunk://#diff-12783128521e452af0cfac94b99b8d250413c516ec71fe6d97dbea666ff7ba27L8-R14)
[[2]](diffhunk://#diff-9917ddc9f1c3304218f7269265b746d997c5c0615478177b5fceecd33ef47cb5R5-R6)
[[3]](diffhunk://#diff-9917ddc9f1c3304218f7269265b746d997c5c0615478177b5fceecd33ef47cb5R126-R129)
[[4]](diffhunk://#diff-dd4fbda47e51f1e35defb9275a9cd9c212ecde0b870cba89ddaaae65c5f3cd28R89-R106)
* Updated `.devcontainer/devcontainer.json` to use the latest Go 1.25.3
version for consistency.

**Licensing and Miscellaneous**

* Added `apps/docs/` to the list of licensed directories in
`LICENSING.md`.

These changes ensure the documentation is now maintained in a modern,
scalable system and all project tooling is updated to support the new
structure.

---------

Co-authored-by: Federico Coppede <[email protected]>
This commit is contained in:
Florian Forster
2026-01-29 08:38:36 -08:00
committed by GitHub
co-authored by Federico Coppede
parent 7b6395c348
commit d8ea041c56
2826 changed files with 78739 additions and 23571 deletions
+286
View File
@@ -0,0 +1,286 @@
import { register } from 'node:module';
import { pathToFileURL } from 'node:url';
import { join, dirname, resolve } from 'node:path';
import { writeFileSync, existsSync, writeSync } from 'node:fs';
import { readFile, stat } from 'node:fs/promises';
register('./scripts/loader.mjs', pathToFileURL('./'));
register('fumadocs-mdx/node/loader', import.meta.url);
import {
type FileObject,
printErrors,
scanURLs,
validateFiles,
} from 'next-validate-link';
try {
// Dynamic import to ensure loader is registered before importing source
const { source, versionSource } = await import('../lib/source');
const PUBLIC_ROOT = resolve('public');
// Simple p-limit implementation to avoid adding dependency
function pLimit(concurrency: number) {
const queue: (() => void)[] = [];
let active = 0;
const next = () => {
active--;
if (queue.length > 0) {
queue.shift()!();
}
};
const run = async <T,>(fn: () => Promise<T>): Promise<T> => {
if (active >= concurrency) {
await new Promise<void>((resolve) => queue.push(resolve));
}
active++;
try {
return await fn();
} finally {
next();
}
};
return run;
}
async function checkLinks() {
const pages = [...source.getPages(), ...versionSource.getPages()];
console.log(`Total pages found: ${pages.length}`);
const allFiles = await getFiles();
const filesToCheck = allFiles.filter((f) => !f.isVersioned);
const allFilesAsTargets = allFiles;
// Load redirects and add them to the scanned URLs
let redirects: any[] = [];
if (existsSync('redirects.json')) {
redirects = JSON.parse(await readFile('redirects.json', 'utf-8'));
}
const scanned = {
urls: new Map<string, { hashes: string[] }>([
// Add /docs specifically as it's the home page and might be linked to
['/docs', { hashes: [] }],
// Load redirects first
...redirects.map((r: any): [string, { hashes: string[] }] => [r.source, { hashes: [] }]),
// Load files second, so they overwrite redirects with actual headings
...allFilesAsTargets.map((f): [string, { hashes: string[] }] => [f.url!, { hashes: getHeadings(f) }]),
]),
fallbackUrls: [],
};
writeFileSync('scanned-urls.json', JSON.stringify(Array.from(scanned.urls.keys()), null, 2));
console.log(`Manually populated URLs count: ${scanned.urls.size}`);
console.time('validateFiles');
let linkErrors: any[] = [];
let currentFile = '';
process.on('uncaughtException', (err) => {
writeSync(1, `\n\n!!! CRITICAL ERROR in ${currentFile} !!!\n`);
console.error(err);
process.exit(1);
});
for (const file of filesToCheck) {
currentFile = file.path;
writeSync(1, `Checking: ${file.path}\n`);
try {
const errors = await validateFiles([file], {
scanned,
baseUrl: '/docs',
markdown: {
components: {
Card: { attributes: ['href'] },
},
},
checkRelativePaths: 'as-url',
});
linkErrors.push(...errors);
} catch (err: any) {
console.error(`\nFAILED file: ${file.path}`);
console.error('Error Details:', err);
if (file.content) {
console.error('First 15 lines of content:');
console.error(file.content.split('\n').slice(0, 15).join('\n'));
}
throw err;
}
}
console.timeEnd('validateFiles');
printErrors(linkErrors, false);
console.time('checkImages');
const imageErrors = await checkImages(filesToCheck);
console.timeEnd('checkImages');
// Check for .md files
const mdFiles = filesToCheck.filter(f => f.path.endsWith('.md'));
if (mdFiles.length > 0) {
console.error('\n❌ Error: Found .md files. All content files must use .mdx extension:');
mdFiles.forEach(f => console.error(` - ${f.path}`));
process.exit(1);
}
if (linkErrors.length === 0 && !imageErrors) {
console.log('\n✅ All checks passed: No broken links or images found.');
}
if (linkErrors.length > 0 || imageErrors) {
process.exit(1);
}
}
async function checkImages(files: (FileObject & { isVersioned?: boolean })[]): Promise<boolean> {
let hasErrors = false;
const limit = pLimit(50); // Limit to 50 concurrent file checks
// Flat list of all image checks
const allChecks: Promise<void>[] = [];
for (const file of files) {
allChecks.push(limit(async () => {
const content = file.content;
const imageRegex = /!\[.*?\]\((.*?)\)|<img.*?src=[{"'](.*?)["'}]/g;
const matches = Array.from(content.matchAll(imageRegex));
for (const match of matches) {
const isMarkdown = !!match[1];
let imagePath = match[1] || match[2];
if (match[1]) {
imagePath = imagePath.trim().split(/\s+/)[0];
}
if (imagePath.startsWith('http') || imagePath.startsWith('https') || imagePath.startsWith('data:')) {
continue;
}
imagePath = imagePath.split('?')[0].split('#')[0];
imagePath = decodeURIComponent(imagePath);
// HTML Image Rules
if (!isMarkdown) {
if (imagePath.includes('public/')) {
console.error(`Broken HTML image link in ${file.path}: ${imagePath} (HTML tags cannot reference 'public/' directly, use absolute path '/docs/img/...')`);
hasErrors = true;
continue;
}
if (!imagePath.startsWith('/docs/')) {
console.error(`Broken HTML image link in ${file.path}: ${imagePath} (HTML tags must use absolute path starting with '/docs/, e.g. '/docs/img/...')`);
hasErrors = true;
continue;
}
// Check existence for absolute /docs/ path
const relativeToPublic = imagePath.slice(5); // Remove /docs
const fullPath = join(PUBLIC_ROOT, relativeToPublic);
try {
await stat(fullPath);
} catch {
console.error(`Broken image link in ${file.path}: ${imagePath} (File not found at ${fullPath})`);
hasErrors = true;
}
continue;
}
// Markdown Image Rules
let fullPath;
if (imagePath.startsWith('/')) {
// Markdown absolute paths are relative to public (usually) or not supported by webpack import if not aliased
// We'll assume they map to public if they start with slash, but fumadocs might prefer relative.
// For now, let's just check existence relative to public if absolute.
// BUT, if they start with /docs/, that's likely wrong for Markdown import if it expects file path.
if (imagePath.startsWith('/docs/')) {
// Be lenient or strict? User had issues with imports.
// Let's rely on standard resolution.
const relativeToPublic = imagePath.slice(5);
fullPath = join(PUBLIC_ROOT, relativeToPublic);
} else {
fullPath = join(PUBLIC_ROOT, imagePath.startsWith('/') ? imagePath.slice(1) : imagePath);
}
} else {
fullPath = resolve(dirname(file.path), imagePath);
}
try {
await stat(fullPath);
} catch {
console.error(`Broken image link in ${file.path}: ${imagePath}`);
hasErrors = true;
}
}
}));
}
await Promise.all(allChecks);
return hasErrors;
}
function getHeadings({ data, content }: any): string[] {
const headings = new Set<string>();
if (data.toc && data.toc.length > 0) {
data.toc.forEach((item: any) => headings.add(item.url.slice(1)));
}
if (data.structuredData?.headings) {
data.structuredData.headings.forEach((h: any) => headings.add(h.id));
}
// Fallback: parse content directly
if (content) {
const lines = content.split('\n');
let inCodeBlock = false;
for (const line of lines) {
if (line.trim().startsWith('```')) {
inCodeBlock = !inCodeBlock;
continue;
}
if (inCodeBlock) continue;
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match) {
const title = match[2].trim();
const slug = title
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/_/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
headings.add(slug);
}
}
}
return Array.from(headings);
}
async function getFiles() {
const pages = [...source.getPages(), ...versionSource.getPages()];
const promises = pages.map(
async (page: any): Promise<FileObject & { isVersioned: boolean }> => {
const path = page.file?.path || page.absolutePath;
return {
path,
content: await readFile(path, 'utf8'),
// REMOVED /docs prefix here to ensure strict link checking against actual routes
url: page.url.startsWith('/') ? page.url : '/' + page.url,
data: page.data,
isVersioned: path.includes('/v4.') || page.url.startsWith('v4.'),
};
},
);
return Promise.all(promises);
}
await checkLinks();
} catch (err) {
writeSync(1, `\n\n!!! TOP-LEVEL ERROR !!!\n`);
console.error(err);
process.exit(1);
}
+89
View File
@@ -0,0 +1,89 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import yaml from 'js-yaml';
const V1_BASE_PATHS: Record<string, string> = {
'admin.openapi.json': '/admin/v1',
'auth.openapi.json': '/auth/v1',
'management.openapi.json': '/management/v1',
'system.openapi.json': '/system/v1',
};
async function walk(dir: string): Promise<string[]> {
let files: string[] = [];
const list = await fs.readdir(dir);
for (const file of list) {
const filepath = path.join(dir, file);
const stat = await fs.stat(filepath);
if (stat.isDirectory()) {
files = files.concat(await walk(filepath));
} else {
if (file.endsWith('.openapi.json')) {
files.push(filepath);
}
}
}
return files;
}
function removeAdditionalPropertiesFalse(obj: any) {
if (typeof obj !== 'object' || obj === null) return;
if (Array.isArray(obj)) {
for (const item of obj) {
removeAdditionalPropertiesFalse(item);
}
return;
}
if (obj.additionalProperties === false) {
delete obj.additionalProperties;
}
for (const key in obj) {
removeAdditionalPropertiesFalse(obj[key]);
}
}
async function cleanOpenApi() {
const openApiDir = path.join(process.cwd(), 'openapi');
try {
await fs.access(openApiDir);
} catch {
console.log('No openapi directory found.');
return;
}
const files = await walk(openApiDir);
for (const file of files) {
const content = await fs.readFile(file, 'utf-8');
const filename = path.basename(file);
let doc: any;
try {
doc = JSON.parse(content);
} catch (e) {
console.error(`Error parsing JSON for ${file}:`, e);
continue;
}
// 1. Inject servers block for v1 APIs
if (filename in V1_BASE_PATHS) {
doc.servers = [
{
url: V1_BASE_PATHS[filename],
description: "ZITADEL " + filename.split('.')[0] + " API v1"
}
];
}
// 2. Remove additionalProperties: false
removeAdditionalPropertiesFalse(doc);
await fs.writeFile(file, JSON.stringify(doc, null, 2));
}
}
cleanOpenApi();
+58
View File
@@ -0,0 +1,58 @@
import fs from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = join(__dirname, '..');
const PROTO_DIR = join(ROOT_DIR, '../../proto');
const CONTENT_DIR = join(ROOT_DIR, 'content');
const CONTENT_API_DIR = join(ROOT_DIR, 'content/reference/api');
const PUBLIC_DIR = join(ROOT_DIR, 'public/docs');
const OPENAPI_DIR = join(ROOT_DIR, 'openapi');
const VERSIONS_FILE = join(ROOT_DIR, 'content/versions.json');
const NEXT_DIR = join(ROOT_DIR, '.next');
const targets = [
NEXT_DIR,
OPENAPI_DIR,
join(ROOT_DIR, '.source'),
join(ROOT_DIR, '.temp'),
VERSIONS_FILE,
CONTENT_API_DIR,
];
async function run() {
console.log('Cleaning up generated artifacts...');
for (const target of targets) {
if (fs.existsSync(target)) {
console.log(`Removing ${target}...`);
fs.rmSync(target, { recursive: true, force: true });
}
}
// Versioned content and protos
const cleanVersioned = (dir, pattern) => {
if (!fs.existsSync(dir)) return;
const files = fs.readdirSync(dir);
for (const file of files) {
if (file === 'latest' || (file.startsWith('v') && !isNaN(parseInt(file[1])))) {
const p = join(dir, file);
console.log(`Removing ${p}...`);
fs.rmSync(p, { recursive: true, force: true });
}
}
};
cleanVersioned(ROOT_DIR);
cleanVersioned(PROTO_DIR);
cleanVersioned(PUBLIC_DIR);
cleanVersioned(CONTENT_DIR);
console.log('Cleanup complete.');
}
run().catch(err => {
console.error(err);
process.exit(1);
});
+547
View File
@@ -0,0 +1,547 @@
import fs from 'fs';
import path, { join, dirname, resolve } from 'path';
import { spawn, execSync } from 'child_process';
import { fileURLToPath } from 'url';
import semver from 'semver';
import { Readable } from 'stream';
const FALLBACK_VERSION = 'v4.10.0'; // Temporary fallback until > 4.10.0 exists
const FALLBACK_BRANCH = 'fuma-docs'; // Primary branch to check for 4.10.0 content
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = join(__dirname, '..');
const PROTO_DIR = join(ROOT_DIR, '../../proto');
const CONTENT_DIR = join(ROOT_DIR, 'content');
const PUBLIC_DIR = join(ROOT_DIR, 'public');
const VERSIONS_FILE = join(ROOT_DIR, 'content/versions.json');
const CONTENT_LATEST_DIR = join(ROOT_DIR, 'content');
console.log(`[fetch-docs] __dirname: ${__dirname}`);
console.log(`[fetch-docs] ROOT_DIR: ${ROOT_DIR}`);
console.log(`[fetch-docs] PROTO_DIR: ${PROTO_DIR}`);
console.log(`[fetch-docs] CONTENT_DIR: ${CONTENT_DIR}`);
const REPO = 'zitadel/zitadel';
const CUTOFF = '4.10.0';
async function fetchTags() {
const token = process.env.GITHUB_TOKEN;
const headers = {
'User-Agent': 'node-fetch'
};
if (token) {
headers['Authorization'] = `token ${token}`;
}
const url = `https://api.github.com/repos/${REPO}/tags?per_page=100`;
console.log(`Fetching tags from ${url}...`);
const res = await fetch(url, { headers });
if (!res.ok) {
const body = await res.text();
throw new Error(`Failed to fetch tags: ${res.statusText} - ${body}`);
}
const tags = await res.json();
console.log(`Fetched ${tags.length} tags.`);
return tags;
}
function filterVersions(tags) {
console.log(`Filtering tags with cutoff strictly > ${CUTOFF}...`);
const versions = tags
.map(t => t.name)
.filter(v => {
const valid = semver.valid(v);
if (!valid) return false;
// Strict cutoff: Do not fetch or build anything older (including that version)
return semver.gt(v, CUTOFF);
})
.sort((a, b) => semver.rcompare(a, b));
console.log(`Found ${versions.length} versions matching criteria.`);
const groups = new Map();
for (const v of versions) {
const majorMinor = `${semver.major(v)}.${semver.minor(v)}`;
if (!groups.has(majorMinor)) {
groups.set(majorMinor, v);
}
}
// User requested Highest + 2 minor versions below it = 3 versions total
const result = Array.from(groups.values()).slice(0, 3);
console.log(`Selected versions: ${result.join(', ')}`);
return result;
}
// Helper to determine the best branch for v4.10.0 content
function getFallbackSource() {
// Prioritize CI/CD Environment Variables
if (process.env.VERCEL_GIT_COMMIT_REF) {
console.log(`[fallback] Detected Vercel Branch: ${process.env.VERCEL_GIT_COMMIT_REF}`);
return process.env.VERCEL_GIT_COMMIT_REF;
}
if (process.env.GITHUB_REF_NAME) {
console.log(`[fallback] Detected GitHub Action Branch: ${process.env.GITHUB_REF_NAME}`);
return process.env.GITHUB_REF_NAME;
}
// Fallback for local dev if git is available
try {
const branch = execSync('git branch --show-current').toString().trim();
if (branch) return branch;
} catch (e) {
// Ignore git errors
}
console.log(`[fallback] Defaulting to ${FALLBACK_BRANCH}`);
return FALLBACK_BRANCH;
}
// sourceRef: Can be a tag (v1.2.3) or a branch (main, fuma-docs)
async function downloadVersion(tag, sourceRef) {
const isBranch = sourceRef === 'main' || sourceRef === 'fuma-docs' || !sourceRef.startsWith('v');
const typeSegment = isBranch ? 'heads' : 'tags';
const url = `https://github.com/${REPO}/archive/refs/${typeSegment}/${sourceRef}.tar.gz`;
const tempDir = join(ROOT_DIR, `.temp/${tag}`); // Extract to tag-specific temp to avoid collisions
fs.mkdirSync(tempDir, { recursive: true });
console.log(`Downloading content for ${tag} (using source: ${sourceRef})...`);
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to download ${url}: ${res.statusText}`);
const tarArgs = [
'-xz',
'-C', tempDir,
`--strip-components=1`,
`zitadel-${sourceRef.replace(/\//g, '-')}/apps/docs/content`, // GitHub archive naming usually matches ref name with slashes replaced? No, usually zitadel-ref. For tags it might vary.
// Actually, GitHub archives top folder is usually `repo-ref`.
// For `v4.10.0` -> `zitadel-4.10.0`. For `fuma-docs` -> `zitadel-fuma-docs`.
// We should probably rely on wildcard or just strict assumption.
// Let's use a wildcard for the top directory since we strip it anyway?
// wait, --strip-components=1 removes the top level folder, whatever it is.
// BUT we are explicitly listing paths INSIDE that folder to extract.
// `tar` requires separate arguments for paths.
// If we don't know the exact top folder name, we can't restrict extraction efficiently without wildcards.
// BUT standard `tar` doesn't always support wildcards in extraction list nicely.
// Strategy: Extract the whole thing? No, too big.
// Solution: The top folder name is predictable.
// Branch: `zitadel-<branch-name>`
// Tag: `zitadel-<tag-name>` (usually without 'v' if tag has 'v'? No, usually matches tag exactly).
// Let's refine the top folder guess.
];
// GitHub archive folder name logic:
// Refs/heads/fuma-docs -> zitadel-fuma-docs
// Refs/tags/v4.10.0 -> zitadel-4.10.0 (often strips 'v'?) OR zitadel-v4.10.0?
// It's inconsistent.
// Better approach: Download and list first? Or extract everything and move?
// Let's try to extract specific paths but use a wildcard if possible?
// Actually, preventing the extract of the whole repo is good.
// Let's guess:
let topFolder = `zitadel-${sourceRef}`;
// Tags often strip 'v' in the folder name if using 'archive/vX.Y.Z.tar.gz' vs 'archive/refs/tags/vX.Y.Z.tar.gz'
// using refs/tags/... generally preserves it or uses the tag name.
// Let's try to just extract *apps/docs/content* with a wildcard pattern if supported?
// `*/apps/docs/content`
// Update tarArgs to be safer with wildcards if using GNU tar, but macos bsdtar differs.
// Safest: Extract all, then filter? Repository is large.
// Let's stick to the previous code's assumption but make it dynamic.
// Previous code: `zitadel-${MOCK_REF}/...`
// If sourceRef is 'v4.10.0', try `zitadel-4.10.0` or `zitadel-v4.10.0`.
// Let's just assume `zitadel-${sourceRef}` or `zitadel-${sourceRef.replace(/^v/, '')}`
// Hack: We can peak at the first file entries? No.
// Let's just try to extract `*/apps/docs/content` using --wildcards if on linux (which we are).
const tarArgsWildcard = [
'-xz',
'-C', tempDir,
'--strip-components=1',
'--wildcards',
'*/apps/docs/content',
'*/apps/docs/public',
'*/cmd/defaults.yaml',
'*/cmd/setup/steps.yaml'
];
await new Promise((resolve, reject) => {
const tar = spawn('tar', tarArgsWildcard);
Readable.fromWeb(res.body).pipe(tar.stdin);
tar.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`tar exited ${code}`))));
tar.stderr.on('data', d => {
const msg = d.toString();
if (!msg.includes('Not found in archive')) console.error(msg);
});
});
// Name normalization logic for destination
// If tag is 'v4.10.0', use only 'v4.10' for folder (matches current logic)
const versionSlug = `v${semver.major(tag)}.${semver.minor(tag)}`;
const contentDest = join(CONTENT_DIR, versionSlug);
const publicDest = join(PUBLIC_DIR, versionSlug);
fs.mkdirSync(dirname(contentDest), { recursive: true });
fs.mkdirSync(dirname(publicDest), { recursive: true });
fs.rmSync(contentDest, { recursive: true, force: true });
fs.rmSync(publicDest, { recursive: true, force: true });
// Move content
if (fs.existsSync(join(tempDir, 'apps/docs/content'))) {
fs.renameSync(join(tempDir, 'apps/docs/content'), contentDest);
} else {
// Fallback: sometimes unzipping structure might differ if wildcards matched differently?
// Check if there is only one folder in tempDir?
// With strip-components=1 and wildcards, it "should" land in tempDir/apps/docs/content directly
// IF the wildcards matched `topfolder/apps/docs/content`.
// If `apps/docs/content` is not there, check for `content` root?
// Let's verify commonly used structure.
if (!fs.existsSync(join(tempDir, 'apps/docs/content'))) {
console.warn(`[warn] apps/docs/content not found in archive for ${tag} (ref: ${sourceRef})`);
}
}
// Handle external files (defaults.yaml etc)
const externalDir = join(contentDest, '_external/cmd');
fs.mkdirSync(externalDir, { recursive: true });
if (fs.existsSync(join(tempDir, 'cmd/defaults.yaml'))) {
fs.cpSync(join(tempDir, 'cmd/defaults.yaml'), join(externalDir, 'defaults.yaml'));
}
if (fs.existsSync(join(tempDir, 'cmd/setup/steps.yaml'))) {
fs.mkdirSync(join(externalDir, 'setup'), { recursive: true });
fs.cpSync(join(tempDir, 'cmd/setup/steps.yaml'), join(externalDir, 'setup/steps.yaml'));
}
if (fs.existsSync(join(tempDir, 'apps/docs/public'))) {
fs.renameSync(join(tempDir, 'apps/docs/public'), publicDest);
}
fs.rmSync(tempDir, { recursive: true, force: true });
}
async function downloadFileContent(tagOrBranch, repoPath) {
const url = `https://raw.githubusercontent.com/${REPO}/${tagOrBranch}/${repoPath}`;
const res = await fetch(url);
if (!res.ok) {
// Fallback for some repo structures if needed, or if file doesn't exist in that version
return null;
}
return await res.text();
}
async function fixRelativeImports(versionDir, tagOrBranch) {
if (!fs.existsSync(versionDir)) return;
const files = fs.readdirSync(versionDir, { recursive: true });
// We'll traverse all files to fix links/imports
for (const file of files) {
const filePath = join(versionDir, file);
if (!fs.statSync(filePath).isFile()) continue;
if (!filePath.endsWith('.mdx') && !filePath.endsWith('.md')) continue;
let content = fs.readFileSync(filePath, 'utf8');
let changed = false;
// Helper to rewrite a single path
// Returns null if no change needed, or the new string if changed
const rewritePath = (originalRelPath) => {
// 1. Resolve where it was originally pointing
const versionFolder = path.basename(versionDir);
const relativePathInContent = filePath.split(join('content', versionFolder))[1];
if (!relativePathInContent) return null; // Should not happen given logic
// The original file was at CONTENT_LATEST_DIR + relativePathInContent
const originalFilePath = join(CONTENT_LATEST_DIR, relativePathInContent);
const originalDir = dirname(originalFilePath);
// Resolve target
// Note: resolve() handles '..' correctly
const absoluteTarget = resolve(originalDir, originalRelPath);
const projectRoot = resolve(ROOT_DIR, '../..'); // Repo root (apps/docs/../..)
// 2. Determine where this target lives now
// Case A: It's in 'public' -> We moved public assets to 'public/<version>'
// Check if target starts with ROOT_DIR/public
if (absoluteTarget.startsWith(PUBLIC_DIR)) {
// It refers to a public asset.
// The NEW location of this asset is PUBLIC_DIR/<version>/<rest of path>
// But wait, PUBLIC_DIR is 'docs/public'.
// In downloadVersion, we move 'zitadel-xxx/docs/public' to 'docs/public/<version>'.
// So if original path was 'docs/public/img/foo.png', new path is 'docs/public/<version>/img/foo.png'.
const relToPublic = absoluteTarget.slice(PUBLIC_DIR.length + 1); // 'img/foo.png'
const newTargetAbs = join(PUBLIC_DIR, versionFolder, relToPublic);
// Now calculate relative path from the NEW file location to this NEW target
// New file is at 'filePath'
const newRelPath = path.relative(dirname(filePath), newTargetAbs);
return newRelPath.split(path.sep).join('/'); // Normalize to forward slashes
}
// Case B: It's in 'docs/content' (linking to another doc)
// If it's a relative link to another doc, we usually want to keep it relative
// content/<ver>/a.md -> content/<ver>/b.md is same relative relationship as content/a.md -> content/b.md
// UNLESS it crosses out of content?
// Case C: It's external (e.g. cmd/defaults.yaml)
// This is what the original code was handling.
// If it is NOT in docs/content, and NOT in docs/public, we treat it as external file to download.
if (absoluteTarget.startsWith(projectRoot) && !absoluteTarget.startsWith(CONTENT_LATEST_DIR) && !absoluteTarget.startsWith(PublicOrRootPublic(absoluteTarget))) {
// External file logic...
// For now, let's keep the original logic for downloading defaults.yaml
return null; // We handle this in the regex pass below specially or reuse this logic?
}
// Handle references to defaults.yaml or setup/steps.yaml specially
if (originalRelPath.includes('cmd/defaults.yaml') || originalRelPath.includes('cmd/setup/steps.yaml')) {
return null; // Let the external file handlers deal with it
}
// Case D: It's a relative link to, say, components/ or other things in docs/
// Original: docs/content/foo.md -> ../components/Bar
// New: docs/content/<ver>/foo.md -> ../../components/Bar
// We just need to recalculate relative path from NEW file to ORIGINAL target
// If it's pointing to something in docs/ (but not content/ or public/), like components/
if (absoluteTarget.startsWith(ROOT_DIR) && !absoluteTarget.startsWith(CONTENT_LATEST_DIR)) {
const newRelPath = path.relative(dirname(filePath), absoluteTarget);
return newRelPath.split(path.sep).join('/');
}
return null;
};
// Helper for detecting public dir properly
// The PUBLIC_DIR variable is 'docs/public'. But absoluteTarget might be resolved via 'src'
function PublicOrRootPublic(p) {
return PUBLIC_DIR;
}
// --- Replacements ---
// 1. Imports: import ... from '...'
// We capture the path: group 2
const importRegex = /(import\s+.*?\s+from\s+['"])([^'"]+)(['"])/g;
content = content.replace(importRegex, (match, p1, p2, p3) => {
if (!p2.startsWith('.')) return match; // Only relative
const rewritten = rewritePath(p2);
if (rewritten && rewritten !== p2) {
changed = true;
return `${p1}${rewritten}${p3}`;
}
// External file handling (legacy logic preserved/adapted)
// If rewritePath returned null, maybe it is the "external download" case?
// Let's implement the external download check here if rewritePath didn't handle it.
// ... (The original logic specifically looked for `../../../../../cmd` etc)
// We can incorporate it into rewritePath or do it here.
// Let's check for the cmd/defaults.yaml case explicitly if rewritePath failed
if (p2.includes('/cmd/')) {
// Re-use logic for downloading external files?
// It's cleaner to put it in rewritePath, but I need async. replace does not support async.
// This function 'fixRelativeImports' is async. I can collect matches first.
}
return match;
});
// 2. Markdown Images: ![alt](src)
const mdImgRegex = /(!\[.*?\]\()([^\)]+)(\))/g;
content = content.replace(mdImgRegex, (match, p1, p2, p3) => {
if (!p2.startsWith('.')) return match;
const rewritten = rewritePath(p2);
if (rewritten && rewritten !== p2) {
changed = true;
return `${p1}${rewritten}${p3}`;
}
return match;
});
// 3. HTML Attributes: src="..." or href="..."
// Naive regex, but likely sufficient for MDX
const htmlAttrRegex = /(src|href)=['"]([^'"]+)['"]/g;
content = content.replace(htmlAttrRegex, (match, attr, val) => {
if (!val.startsWith('.')) return match;
const rewritten = rewritePath(val);
if (rewritten && rewritten !== val) {
changed = true;
// reconstruct match
const quote = match.includes("'") ? "'" : '"';
return `${attr}=${quote}${rewritten}${quote}`;
}
return match;
});
// --- Special handling for the "download external file" case ---
// The previous regex replacement structure prevents async operations.
// We have to scan for external files separately or before replacing, OR use a synchronous download (not ideal)
// OR rely on the existing synchronous logic for rewriting if we pre-download them.
// Let's perform a SCAN for strictly external files (like ../cmd/defaults.yaml) first
// Reuse original logic for downloading
const importRegexForDownload = /import\s+.*\s+from\s+['"](\.\.\/(\.\.\/)+[^'"]+)['"]/g;
let match;
while ((match = importRegexForDownload.exec(content)) !== null) {
const relPath = match[1];
const versionFolder = path.basename(versionDir);
const relativePathInContent = filePath.split(join('content', versionFolder))[1];
const originalFilePath = join(CONTENT_LATEST_DIR, relativePathInContent);
const absoluteImportTarget = resolve(dirname(originalFilePath), relPath);
const projectRoot = resolve(ROOT_DIR, '../..');
// If it points to cmd/ or similar external
if (absoluteImportTarget.startsWith(projectRoot) && !absoluteImportTarget.startsWith(CONTENT_LATEST_DIR) && !absoluteImportTarget.startsWith(PUBLIC_DIR)) {
const repoRoot = resolve(ROOT_DIR, '../..');
// The original imports were relative to docs/content, not apps/docs/content.
// We need to account for the extra 'apps/' level.
let relativeToRepoRoot;
if (absoluteImportTarget.startsWith(join(repoRoot, 'apps'))) {
// If it resolved to apps/cmd/defaults.yaml, it should be cmd/defaults.yaml
relativeToRepoRoot = absoluteImportTarget.replace(join(repoRoot, 'apps') + '/', '');
} else {
relativeToRepoRoot = absoluteImportTarget.replace(repoRoot + '/', '');
}
const localPathInVersion = join(versionDir, '_external', relativeToRepoRoot);
// Download if missing
if (!fs.existsSync(localPathInVersion)) {
console.log(`[fix-imports] Downloading external: ${relativeToRepoRoot}`);
const fileContent = await downloadFileContent(tagOrBranch, relativeToRepoRoot); // existing function
if (fileContent) {
fs.mkdirSync(dirname(localPathInVersion), { recursive: true });
fs.writeFileSync(localPathInVersion, fileContent);
}
}
// Rewrite to local path
const newRelPath = path.relative(dirname(filePath), localPathInVersion).split(path.sep).join('/');
const finalPath = newRelPath.startsWith('.') ? newRelPath : './' + newRelPath;
// Perform replacement
const newImport = match[0].replace(relPath, finalPath);
content = content.replace(match[0], newImport);
changed = true;
}
}
if (changed) {
console.log(`[fix-relative] Updated ${file}`);
fs.writeFileSync(filePath, content);
}
}
}
function getLocalVersion() {
const isVercel = process.env.VERCEL === '1';
const vercelBranch = process.env.VERCEL_GIT_COMMIT_REF;
let branch = vercelBranch;
if (!branch) {
try {
branch = execSync('git branch --show-current').toString().trim();
} catch (e) {}
}
if (branch && branch !== 'main') {
return { label: branch, isUnreleased: true };
}
try {
const tag = execSync('git describe --tags --abbrev=0').toString().trim();
if (semver.valid(tag) && semver.gt(tag, CUTOFF)) {
return { label: tag, isUnreleased: false };
}
} catch (e) {}
return { label: 'v4.11.0', isUnreleased: true };
}
async function run() {
console.log('Starting version discovery...');
const tags = await fetchTags();
const selectedTags = filterVersions(tags);
let localVer = getLocalVersion();
let others = selectedTags; // In our case, if local is latest, all filtered tags are others
console.log(`Latest version (Local): ${localVer.label} (Unreleased: ${localVer.isUnreleased})`);
// Conditional Fallback: If no versions found > 4.10.0, inject v4.10.0
if (others.length === 0) {
console.log(`[fallback] No versions found strictly > ${CUTOFF}. Injecting ${FALLBACK_VERSION} as fallback.`);
others.push(FALLBACK_VERSION);
}
console.log(`Older versions to fetch: ${others.join(', ') || 'None'}`);
// Download chosen versions
// Parallelize download and processing
await Promise.all(others.map(async (tag) => {
let sourceRef = tag;
// Explicit logic for version 4.10.0 to use active branch
if (tag === FALLBACK_VERSION || tag === '4.10.0') {
sourceRef = getFallbackSource();
}
const versionSlug = `v${semver.major(tag)}.${semver.minor(tag)}`;
const contentDest = join(CONTENT_DIR, versionSlug);
// Simple cache check: if directory exists and looks populated, skip
// We could check for a specific file like meta.json or similar if we wanted to be more robust
if (fs.existsSync(contentDest)) {
console.log(`[skip] Version ${versionSlug} already exists. Skipping download.`);
} else {
await downloadVersion(tag, sourceRef);
// Only fix imports if we just downloaded it (or maybe always run it? Safe to rerun)
// Rerunning fixRelativeImports is relatively cheap compared to download + tar extraction
// but let's stick to doing it only if we downloaded or if we force it.
// For now: Always fix imports to be safe, or just on new download.
// Let's doing it on new download for speed.
await fixRelativeImports(contentDest, tag);
}
}));
// Generate versions.json
const versionsJson = [
{
param: 'latest',
label: localVer.isUnreleased ? `${localVer.label} (Unreleased)` : `${localVer.label} (Latest)`,
url: '/docs',
ref: 'local',
refType: 'local'
}
];
for (const tag of others) {
const v = `v${semver.major(tag)}.${semver.minor(tag)}`;
const versionSlug = `v${semver.major(tag)}${semver.minor(tag)}x`;
const targetUrl = `https://docs-git-${versionSlug}-zitadel.vercel.app/docs`;
versionsJson.push({
param: v,
label: v,
url: `/docs/${v}`,
ref: tag,
refType: 'tag',
target: targetUrl
});
}
fs.writeFileSync(VERSIONS_FILE, JSON.stringify(versionsJson, null, 2));
console.log('versions.json generated successfully.');
}
run().catch(err => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1,241 @@
import { generateFiles } from 'fumadocs-openapi';
import { createOpenAPI } from 'fumadocs-openapi/server';
import { writeFileSync, mkdirSync, readdirSync, lstatSync, readFileSync, existsSync } from 'fs';
import path, { join, dirname, basename } from 'path';
import { fileURLToPath } from 'url';
import { glob } from 'glob';
import yaml from 'js-yaml';
// Suppress "Generated: ..." logs to avoid Vercel log limits
const originalLog = console.log;
console.log = (...args) => {
if (args.length > 0 && typeof args[0] === 'string' && args[0].startsWith('Generated: ')) {
return;
}
originalLog(...args);
};
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = join(__dirname, '..');
const OPENAPI_ROOT = join(ROOT_DIR, 'openapi');
const CONTENT_ROOT = join(ROOT_DIR, 'content');
const CONTENT_VERSIONS_ROOT = join(ROOT_DIR, 'content');
async function generateVersionApiDocs(version: string) {
const sourceRoot = join(OPENAPI_ROOT, version);
if (!existsSync(sourceRoot)) return;
const outputRoot = version === 'latest'
? join(CONTENT_ROOT, 'reference/api')
: join(CONTENT_VERSIONS_ROOT, `${version}/reference/api`);
console.log(`Generating API docs for version: ${version} -> ${outputRoot}`);
mkdirSync(outputRoot, { recursive: true });
const specs = await glob('**/*.openapi.json', { cwd: sourceRoot });
const services: string[] = [];
for (const specPath of specs) {
const fullPath = join(sourceRoot, specPath);
const content = readFileSync(fullPath, 'utf8');
const doc = JSON.parse(content) as any;
if (!doc.paths || Object.keys(doc.paths).length === 0) continue;
let service = basename(specPath, '.openapi.json');
if (service.endsWith('_service')) {
service = service.slice(0, -8);
}
// For services in subdirectories (like resource/userschema),
// we want a unique but readable name.
const relDir = dirname(specPath);
const folderPrefix = relDir !== '.' && !relDir.startsWith('zitadel')
? relDir.split('/').pop() + '-'
: '';
const uniqueService = folderPrefix + service;
const outputDir = join(outputRoot, uniqueService);
services.push(uniqueService);
const api = createOpenAPI({
input: [path.relative(ROOT_DIR, fullPath)],
});
await generateFiles({
input: api,
output: outputDir,
includeDescription: true,
});
const indexPath = join(outputDir, 'index.mdx');
const title = uniqueService.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' ');
const indexContent = `---
title: ${title} API
---
API Reference for ${title}
`;
writeFileSync(indexPath, indexContent);
}
// Generate meta.json
const meta = {
title: "APIs",
pages: services
.filter(s => !s.includes('beta') && !s.includes('alpha'))
.sort()
};
writeFileSync(
join(outputRoot, 'meta.json'),
JSON.stringify(meta, null, 2)
);
}
async function fixAllGeneratedLinks() {
console.log('Post-processing: Fixing API links...');
const files = await glob('**/*.{md,mdx}', { cwd: CONTENT_ROOT });
const fileIndex = new Map<string, Map<string, string>>(); // version -> (Name -> absoluteURL)
const getVersion = (filePath: string) => {
const parts = filePath.split(path.sep);
if (parts[0].startsWith('v4.')) return parts[0];
return 'latest';
};
const getUrl = (filePath: string) => {
return '/' + filePath.replace(/\.(md|mdx)$/, '').split(path.sep).join('/');
};
// Build index
for (const file of files) {
const version = getVersion(file);
if (!fileIndex.has(version)) fileIndex.set(version, new Map());
const versionMap = fileIndex.get(version)!;
const name = basename(file.replace(/\.md$/, ''), '.mdx');
const parts = name.split('.');
const operation = parts[parts.length - 1];
const service = parts.length > 1 ? parts[parts.length - 2] : null;
const url = getUrl(file);
// Prioritize non-beta/non-alpha in the index
const isBeta = name.includes('beta') || name.includes('alpha');
const existing = versionMap.get(operation.toLowerCase());
if (!existing || (!isBeta && existing.includes('beta'))) {
versionMap.set(operation.toLowerCase(), url);
}
if (service) {
const serviceOp = `${service}.${operation}`.toLowerCase();
const existingServiceOp = versionMap.get(serviceOp);
if (!existingServiceOp || (!isBeta && existingServiceOp.includes('beta'))) {
versionMap.set(serviceOp, url);
}
}
versionMap.set(name.toLowerCase(), url);
}
let fixedCount = 0;
for (const file of files) {
const fullPath = join(CONTENT_ROOT, file);
const content = readFileSync(fullPath, 'utf-8');
const version = getVersion(file);
const versionMap = fileIndex.get(version);
// [Text](apis/resources/service_name/file_name.api.mdx)
const linkRegex = /\[([^\]]+)\]\(([\/]?apis\/resources\/([^\/]+)\/([^\/)]+)\.api\.mdx)\)/g;
let modified = false;
let newContent = content.replace(linkRegex, (match, text, fullLink, serviceSlug, fileSlug) => {
const toPascalCase = (s: string) => s.split(/[-_]/).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('');
// Extraction strategy: fileSlug is "user-service-get-user-by-id"
const serviceIndex = fileSlug.lastIndexOf('service-');
const operationKebab = serviceIndex !== -1 ? fileSlug.slice(serviceIndex + 8) : fileSlug;
const operation = toPascalCase(operationKebab);
let targetUrl = versionMap?.get(operation.toLowerCase());
if (!targetUrl && serviceSlug) {
const serviceMatch = serviceSlug.match(/^([a-z_]+)_service/);
if (serviceMatch) {
const service = toPascalCase(serviceMatch[1]) + 'Service';
targetUrl = versionMap?.get(`${service}.${operation}`.toLowerCase());
}
}
if (targetUrl) {
modified = true;
fixedCount++;
return `[${text}](${targetUrl})`;
}
return match;
});
// Fix v2beta links that were likely in the source proto comments
// and also remove potential double /docs prefixing from source comments
const internalLinkRegex = /\[([^\]]+)\]\(([\/]?docs\/)?reference\/api\/([^\/]+)\/([^\s)]+)\)/g;
newContent = newContent.replace(internalLinkRegex, (match, text, docsPrefix, service, fileSlug) => {
let targetFileSlug = fileSlug;
let isV2Beta = fileSlug.includes('.v2beta.');
if (isV2Beta) {
const v2Target = fileSlug.replace('.v2beta.', '.v2.');
// Only rename to v2 if the v2 target actually exists in our index
if (versionMap?.has(v2Target.toLowerCase())) {
targetFileSlug = v2Target;
} else {
// If no v2 target, and we excluded v2beta, this link is dead.
// Return plain text instead of a broken link to satisfy the link checker.
modified = true;
return text;
}
}
const targetUrl = `/reference/api/${service}/${targetFileSlug}`;
if (docsPrefix || isV2Beta) {
modified = true;
return `[${text}](${targetUrl})`;
}
return match;
});
if (modified) {
writeFileSync(fullPath, newContent);
}
}
console.log(`Post-processing: Fixed ${fixedCount} links.`);
}
async function run() {
const args = process.argv.slice(2);
const onlyGenerate = args.includes('--only-generate');
const onlyFix = args.includes('--only-fix');
// Default to both if no specific flag is set
const runAll = !onlyGenerate && !onlyFix;
if (runAll || onlyGenerate) {
if (!existsSync(OPENAPI_ROOT)) {
console.error('OpenAPI root not found. Run generate-buf.mjs first.');
process.exit(1);
}
const versions = readdirSync(OPENAPI_ROOT).filter(f => lstatSync(join(OPENAPI_ROOT, f)).isDirectory() && f !== 'zitadel');
for (const version of versions) {
await generateVersionApiDocs(version);
}
}
if (runAll || onlyFix) {
await fixAllGeneratedLinks();
}
}
run().catch(err => {
console.error(err);
process.exit(1);
});
+236
View File
@@ -0,0 +1,236 @@
import fs from 'node:fs';
import path from 'node:path';
// Adjust path if needed to point to your actual sidebar file
import { guidesSidebar, apisSidebar, legalSidebar } from '../lib/sidebar-data';
const CONTENT_DIR = path.join(process.cwd(), 'content');
// --- HELPERS ---
function ensureDirectoryExistence(filePath: string) {
const dirname = path.dirname(filePath);
if (fs.existsSync(dirname)) {
return true;
}
ensureDirectoryExistence(dirname);
fs.mkdirSync(dirname);
}
function toTitleCase(str: string) {
return str
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
}
/**
* Clean a path segment.
* e.g., "docs/guides/overview" -> "guides/overview"
* e.g., "/guides/overview/" -> "guides/overview"
*/
function cleanPath(p: string) {
if (!p) return '';
return p
.replace(/^\/+/, '') // Remove leading slash
.replace(/^docs\//, '') // Remove leading 'docs/' if present (prevents duplication)
.replace(/\/+$/, ''); // Remove trailing slash
}
/**
* Resolve HREF
*/
function getItemHref(item: any, urlPrefix: string): string {
// Normalize prefix:
// If urlPrefix is '', prefix becomes just '/'
// If urlPrefix is '/v2', prefix becomes '/v2/'
const prefix = urlPrefix ? (urlPrefix.startsWith('/') ? urlPrefix : `/${urlPrefix}`) : '';
let segment = '';
// CASE A: String (Doc ID)
if (typeof item === 'string') {
segment = cleanPath(item);
}
// CASE B: Link (External or Internal)
else if (item.type === 'link' && item.href) {
// If external, return immediately
if (item.href.startsWith('http')) return item.href;
// If absolute path provided manually, return it (e.g. /legal/...)
if (item.href.startsWith('/')) return item.href;
segment = cleanPath(item.href);
}
// CASE C: Doc Object
else if (item.type === 'doc' && item.id) {
segment = cleanPath(item.id);
}
// CASE D: Category (Link to index)
else if (item.type === 'category') {
if (item.link?.slug) {
segment = cleanPath(item.link.slug);
} else if (item.link?.id) {
segment = cleanPath(item.link.id);
}
}
// CASE E: Autogenerated
else if (item.type === 'autogenerated' && item.dirName) {
segment = cleanPath(item.dirName);
}
if (!segment) return '';
// Combine prefix and segment
// Ensure we usually start with / unless prefix was empty and logic demands it
let finalUrl = prefix ? `${prefix}/${segment}` : `/${segment}`;
// FINAL SAFETY CHECK: Deduplicate /docs/docs/
// This catches cases where 'urlPrefix' was empty but 'segment' started with docs/
finalUrl = finalUrl.replace(/\/docs\/docs\//g, '/docs/');
return finalUrl;
}
/**
* Resolve TITLE
*/
function getItemLabel(item: any): string {
if (typeof item === 'string') {
const parts = item.split('/');
return toTitleCase(parts[parts.length - 1]);
}
if (item.label) return item.label;
if (item.id) {
const parts = item.id.split('/');
return toTitleCase(parts[parts.length - 1]);
}
if (item.type === 'autogenerated' && item.dirName) {
const parts = item.dirName.split('/');
return toTitleCase(parts[parts.length - 1]);
}
return 'Section';
}
/**
* Resolve ICON
*/
function getItemIcon(item: any): string {
if (typeof item === 'string') return '<FileText />';
if (item.type === 'category') return '<Folder />';
if (item.type === 'autogenerated') return '<Folder />';
if (item.type === 'link') return '<LinkIcon />';
return '<FileText />';
}
// --- GENERATOR ---
function generateContent(title: string, description: string, items: any[], urlPrefix: string) {
const safeDescription = description
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
const cardsHtml = items
.map((item) => {
const href = getItemHref(item, urlPrefix);
const label = getItemLabel(item);
const icon = getItemIcon(item);
if (!href) return null;
const isExternal = href.startsWith('http');
const externalProp = isExternal ? ' external' : '';
return ` <Card title="${label}" href="${href}" icon={${icon}}${externalProp} />`;
})
.filter(Boolean)
.join('\n');
return `---
title: "${title}"
description: "${safeDescription}"
---
{/* THIS FILE IS AUTO-GENERATED FROM SIDEBAR-DATA.
ANY MANUAL CHANGES WILL BE OVERWRITTEN.
*/}
import { Card, Cards } from 'fumadocs-ui/components/card';
import { FileText, Folder, Link as LinkIcon } from 'lucide-react';
<Cards>
${cardsHtml}
</Cards>
`;
}
function traverse(items: readonly any[], baseDir: string, urlPrefix: string) {
items.forEach((item) => {
// Skip strings (leaves)
if (typeof item === 'string') return;
// Generate page only for categories with 'generated-index'
if (
item.type === 'category' &&
item.link &&
item.link.type === 'generated-index' &&
item.link.slug
) {
const slug = item.link.slug;
const cleanSlug = cleanPath(slug);
const filePath = path.join(baseDir, cleanSlug, 'index.mdx');
console.log(`[+] Generating Virtual Page: ${path.relative(CONTENT_DIR, filePath)}`);
ensureDirectoryExistence(filePath);
const content = generateContent(
item.link.title || item.label || 'Overview',
item.link.description || '',
item.items || [],
urlPrefix
);
fs.writeFileSync(filePath, content);
}
// Recursively traverse
if (item.items) {
traverse(item.items, baseDir, urlPrefix);
}
});
}
// --- EXECUTION ---
const VERSIONS_FILE = path.join(CONTENT_DIR, 'versions.json');
console.log('--- Scanning Sidebar for Virtual Pages ---');
// 1. Generate for Latest
// CRITICAL CHANGE: Pass empty string '' instead of '/docs'
// This generates href="/guides/..." instead of "/docs/guides/..."
// preventing the double prefix if the Component/Link adds it automatically.
console.log(`\nProcessing: Latest`);
traverse(guidesSidebar, CONTENT_DIR, '');
traverse(apisSidebar, CONTENT_DIR, '');
traverse(legalSidebar, CONTENT_DIR, '');
// 2. Generate for Versions
if (fs.existsSync(VERSIONS_FILE)) {
const versions = JSON.parse(fs.readFileSync(VERSIONS_FILE, 'utf8'));
versions.forEach((v: any) => {
if (!v.param || v.param === 'latest' || !v.param.startsWith('v')) return;
const versionDir = path.join(CONTENT_DIR, v.param);
if (!fs.existsSync(versionDir)) return;
console.log(`\nProcessing: ${v.param}`);
// For versions, we prefix with just the version param (e.g. /v2)
// If double docs persist here, change this to just `/${v.param}` or verify version routing.
traverse(guidesSidebar, versionDir, `/${v.param}`);
traverse(apisSidebar, versionDir, `/${v.param}`);
traverse(legalSidebar, versionDir, `/${v.param}`);
});
}
console.log('--- Done ---');
+206
View File
@@ -0,0 +1,206 @@
import fs from 'fs';
import { glob } from 'glob';
import { join, dirname, resolve } from 'path';
import { spawnSync } from 'child_process';
import { fileURLToPath } from 'url';
import os from 'os';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = join(__dirname, '..');
const PROTO_DIR = join(ROOT_DIR, '../../proto');
const OPENAPI_DIR = join(ROOT_DIR, 'openapi');
const VERSIONS_FILE = join(ROOT_DIR, 'content/versions.json');
const REPO_URL = 'https://github.com/zitadel/zitadel.git';
async function run() {
if (!fs.existsSync(VERSIONS_FILE)) {
console.error('versions.json not found. Run fetch-docs.mjs first.');
process.exit(1);
}
const versions = JSON.parse(fs.readFileSync(VERSIONS_FILE, 'utf8'));
console.log(`Processing ${versions.length} versions from versions.json`);
const baseTempDir = fs.mkdtempSync(join(os.tmpdir(), 'zitadel-buf-'));
// Use a subdirectory for local generation to avoid pollution
const localGenDir = join(baseTempDir, 'local');
fs.mkdirSync(localGenDir, { recursive: true });
const templatePath = resolve(join(ROOT_DIR, 'buf.gen.yaml'));
try {
// Simple p-limit implementation to avoid adding dependency
const pLimit = (concurrency) => {
const queue = [];
let active = 0;
const next = () => {
active--;
if (queue.length > 0) {
queue.shift()();
}
};
const run = async (fn) => {
if (active >= concurrency) {
await new Promise((resolve) => queue.push(resolve));
}
active++;
try {
return await fn();
} finally {
next();
}
};
return run;
};
const limit = pLimit(2);
await Promise.all(versions.map(v => limit(async () => {
if (v.type === 'external') return; // Skip archive links
const label = v.param;
const outputPath = resolve(join(OPENAPI_DIR, label));
console.log(`\n--- Generating OpenAPI specs for ${label} ---`);
fs.rmSync(outputPath, { recursive: true, force: true });
fs.mkdirSync(outputPath, { recursive: true });
// Create a unique temp dir for this specific generation task to avoid conflicts
const taskTempDir = join(baseTempDir, label);
fs.mkdirSync(taskTempDir, { recursive: true });
// Determine buf input based on refType
let bufInput;
if (v.refType === 'local') {
// Point to local proto directory (repo root/proto)
bufInput = PROTO_DIR;
} else {
const refPart = v.refType === 'branch' ? `branch=${v.ref}` : `tag=${v.ref}`;
bufInput = `${REPO_URL}#${refPart},subdir=proto`;
}
console.log(`Using input for ${label}: ${bufInput}`);
// Use spawn (async) instead of spawnSync to avoid blocking the event loop
await new Promise(async (resolvePromise, rejectPromise) => {
// Dynamic discovery of excluded paths
const getExcludedPaths = async () => {
const patterns = ['**/v3alpha', '**/v2beta'];
const excluded = new Set();
if (v.refType === 'local') {
// For local, use glob against PROTO_DIR
// patterns are relative to PROTO_DIR, but glob needs to search inside PROTO_DIR
// The result of glob will be paths relative to PROTO_DIR if cwd is PROTO_DIR
try {
const files = await glob(patterns, { cwd: PROTO_DIR, nodir: false });
// We only want directories that contain v3alpha, or files?
// buf --exclude-path expects directories or files.
// If we found directories ending in v3alpha, usage is fine.
// glob '**/v3alpha' matches directories or files ending in v3alpha.
// Ensure we pass absolute paths for local input to avoid CWD resolution issues
files.forEach(f => excluded.add(resolve(PROTO_DIR, f)));
} catch (e) {
console.warn('Failed to glob excluded paths locally', e);
}
} else {
// For remote (tag/branch), use git ls-tree
// We need to run git ls-tree -r <ref> --name-only
// and filter for lines matching /v3alpha/
// We run this in ROOT_DIR (where .git presumably is available via parent)
// The paths returned by git ls-tree are relative to repo root.
// buf input is relative to 'proto' subdir.
// So we need to strip 'proto/' prefix.
try {
const ref = v.ref;
const gitCmd = spawnSync('git', ['ls-tree', '-r', '--name-only', ref], { cwd: ROOT_DIR, encoding: 'utf-8' });
if (gitCmd.error) throw gitCmd.error;
const files = gitCmd.stdout.split('\n');
files.forEach(f => {
if ((f.includes('v3alpha') || f.includes('v2beta')) && f.startsWith('proto/')) {
// strip 'proto/' (6 chars) and take the directory
const relPath = f.substring(6);
const dirPath = dirname(relPath);
if (dirPath.includes('v3alpha') || dirPath.includes('v2beta')) {
excluded.add(dirPath);
} else {
excluded.add(relPath);
}
}
});
} catch (e) {
console.warn(`Failed to list tree for ${v.param}`, e);
}
}
return Array.from(excluded);
};
const excludedPaths = await getExcludedPaths();
if (excludedPaths.length > 0) {
console.log(`Excluding ${excludedPaths.length} paths matching v3alpha or v2beta`);
}
import('child_process').then(({ spawn }) => {
const args = [
'@bufbuild/buf', 'generate',
bufInput,
'--template', templatePath,
'--output', outputPath
];
// Add excluded paths
for (const excludedPath of excludedPaths) {
args.push('--exclude-path', excludedPath);
}
const child = spawn('npx', args, {
cwd: taskTempDir,
stdio: 'inherit',
env: {
...process.env,
BUF_TOKEN: '851d3e2519b882d9e6da46eadec5e3ccc6a966dae0ce5e78dd285d9f912e35fd'
}
});
child.on('close', (code) => {
if (code !== 0) {
rejectPromise(new Error(`Failed to generate OpenAPI for ${label} (exit code ${code})`));
} else {
console.log(`Successfully generated OpenAPI for ${label}`);
// Post-generation cleanup: delete any missed v2beta/v3alpha files
try {
const generatedFiles = glob.sync('**/*', { cwd: outputPath, absolute: true, nodir: false });
generatedFiles.forEach(f => {
if (f.includes('v2beta') || f.includes('v3alpha')) {
fs.rmSync(f, { recursive: true, force: true });
}
});
} catch (e) {
console.warn(`Failed to clean up OpenAPI specs for ${label}`, e);
}
resolvePromise();
}
});
child.on('error', (err) => {
rejectPromise(err);
});
});
});
})));
} finally {
fs.rmSync(baseTempDir, { recursive: true, force: true });
}
}
run().catch(err => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1,17 @@
import { register } from 'node:module';
import { pathToFileURL } from 'node:url';
register('./scripts/loader.mjs', pathToFileURL('./'));
register('fumadocs-mdx/node/loader', import.meta.url);
const { source } = await import('../lib/source');
const pages = source.getPages();
const endpointsPage = pages.find(p => p.url.endsWith('/apis/openidoauth/endpoints'));
if (endpointsPage) {
console.log('Endpoints Page URL:', endpointsPage.url);
console.log('TOC URLs:', endpointsPage.data.toc.map(i => i.url));
} else {
console.log('Endpoints page not found.');
}
+100
View File
@@ -0,0 +1,100 @@
import { readFile } from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
const TEXT_EXTENSIONS = ['.yaml', '.yml', '.conf', '.txt', '.json', '.caddyfile', '.go'];
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.bmp'];
const STYLE_EXTENSIONS = ['.css', '.scss', '.sass', '.less'];
export async function resolve(specifier, context, nextResolve) {
const ext = path.extname(specifier.split('?')[0]).toLowerCase();
try {
return await nextResolve(specifier, context);
} catch (err) {
if (TEXT_EXTENSIONS.includes(ext) || IMAGE_EXTENSIONS.includes(ext) || STYLE_EXTENSIONS.includes(ext)) {
// If it's an absolute path or looks like one, and it failed, we still want to load it
// This happens on Vercel if files are missing on disk but referenced in MDX
let url = specifier;
if (!specifier.startsWith('file:')) {
url = pathToFileURL(path.resolve(specifier)).href;
}
return {
url,
shortCircuit: true,
};
}
throw err;
}
}
export async function load(url, context, nextLoad) {
// Only handle file URLs
if (!url.startsWith('file:')) {
return nextLoad(url, context);
}
const u = new URL(url);
const ext = path.extname(u.pathname).toLowerCase();
// Handle ?raw imports
if (u.searchParams.has('raw')) {
u.search = '';
const filePath = fileURLToPath(u);
const content = await readFile(filePath, 'utf8');
return {
format: 'module',
shortCircuit: true,
source: `export default ${JSON.stringify(content)};`,
};
}
if (TEXT_EXTENSIONS.includes(ext)) {
u.search = '';
const filePath = fileURLToPath(u);
const content = await readFile(filePath, 'utf8');
return {
format: 'module',
shortCircuit: true,
source: `export default ${JSON.stringify(content)};`,
};
}
if (IMAGE_EXTENSIONS.includes(ext)) {
const mockImage = {
src: url,
height: 100,
width: 100,
blurDataURL: 'data:image/png;base64,',
};
return {
format: 'module',
shortCircuit: true,
source: `export default ${JSON.stringify(mockImage)};`,
};
}
if (STYLE_EXTENSIONS.includes(ext)) {
return {
format: 'module',
shortCircuit: true,
source: 'export default {};',
};
}
// Try to use nextLoad, but if it fails for YAML, it's likely because it's from root
try {
return await nextLoad(url, context);
} catch (err) {
if (ext === '.yaml' || ext === '.yml') {
const filePath = fileURLToPath(u);
const content = await readFile(filePath, 'utf8');
return {
format: 'module',
shortCircuit: true,
source: `export default ${JSON.stringify(content)};`,
};
}
throw err;
}
}
+199
View File
@@ -0,0 +1,199 @@
import { glob } from 'glob';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.join(__dirname, '..');
const OLD_XML = path.join(ROOT_DIR, 'old.xml');
const CONTENT_DIR = path.join(ROOT_DIR, 'content');
function extractUrlsFromXml(xmlContent) {
const regex = /<loc>(https:\/\/zitadel\.com\/docs\/[^<]+)<\/loc>/g;
const urls = [];
let match;
while ((match = regex.exec(xmlContent)) !== null) {
urls.push(match[1]);
}
return urls;
}
async function getNewUrlsFromContent() {
const mdxFiles = await glob('**/reference/api/**/*.mdx', { cwd: CONTENT_DIR });
const urls = mdxFiles.map(file => {
let slug = file.replace(/\.mdx$/, '');
return `https://zitadel.com/docs/${slug}`;
});
return urls;
}
function parseOldUrl(url) {
const prefix = 'https://zitadel.com/docs/apis/resources/';
if (!url.startsWith(prefix)) return null;
const rest = url.slice(prefix.length);
const [serviceDir, methodSlug] = rest.split('/');
if (!methodSlug) return { serviceDir, isIndex: true, full: url };
return { serviceDir, methodSlug, full: url };
}
function parseNewUrl(url) {
let rest;
if (url.includes('/reference/api/')) {
rest = url.split('/reference/api/')[1];
} else {
return null;
}
const parts = rest.split('/');
if (parts.length < 2) return null;
const serviceDir = parts[0];
const fileSlug = parts[1];
const methodParts = fileSlug.split('.');
const method = methodParts[methodParts.length - 1];
return { serviceDir, method, full: url, fileSlug };
}
const serviceMapping = {
'action_service_v2': 'action',
'admin': 'admin',
'mgmt': 'management',
'auth': 'auth',
'system': 'system',
'user_service_v2': 'user',
'session_service_v2': 'session',
'oidc_service_v2': 'oidc',
'settings_service_v2': 'settings',
'org_service_v2': 'org',
'org_service/v2': 'org',
'project_service_v2': 'project',
'feature_service_v2': 'feature',
'idp_service_v2': 'idp',
'instance_service_v2': 'instance',
'saml_service_v2': 'saml',
'internal_permission_service_v2': 'internal_permission',
'application_service_v2': 'application',
'authorization_service_v2': 'authorization',
'webkey_service_v2': 'webkey',
};
async function run() {
if (!fs.existsSync(OLD_XML)) {
console.error(`Old sitemap not found at ${OLD_XML}`);
process.exit(1);
}
const oldContent = fs.readFileSync(OLD_XML, 'utf8');
const oldUrls = extractUrlsFromXml(oldContent).filter(u => u.includes('/apis/resources/'));
const newUrls = (await getNewUrlsFromContent()).filter(u => !/\/v\d+\.\d+(\.\d+)?\//.test(u));
console.log(`Scanning content directory: found ${newUrls.length} unversioned new URLs.`);
const newServiceMap = {};
for (const u of newUrls) {
const parsed = parseNewUrl(u);
if (!parsed) continue;
const isVersioned = /\/v\d+\.\d+\.\d+\//.test(u);
if (!newServiceMap[parsed.serviceDir]) newServiceMap[parsed.serviceDir] = [];
if (isVersioned) {
const existing = newServiceMap[parsed.serviceDir].find(n => n.method === parsed.method && n.fileSlug === parsed.fileSlug && !/\/v\d+\.\d+\.\d+\//.test(n.full));
if (existing) continue;
}
newServiceMap[parsed.serviceDir].push(parsed);
}
const redirects = [];
const missing = [];
for (const u of oldUrls) {
const parsed = parseOldUrl(u);
if (!parsed) continue;
if (parsed.isIndex) {
const rawService = parsed.serviceDir;
const mappedService = serviceMapping[rawService] || rawService;
if (newServiceMap[mappedService]) {
redirects.push({
source: u.replace('https://zitadel.com/docs', ''),
destination: `/reference/api/${mappedService}`,
permanent: true
});
} else {
missing.push(u);
}
continue;
}
const mappedService = serviceMapping[parsed.serviceDir];
if (!mappedService || !newServiceMap[mappedService]) {
missing.push(u);
continue;
}
const candidates = newServiceMap[mappedService];
candidates.sort((a, b) => {
const aVersioned = /\/v\d+\.\d+\.\d+\//.test(a.full);
const bVersioned = /\/v\d+\.\d+\.\d+\//.test(b.full);
if (!aVersioned && bVersioned) return -1;
if (aVersioned && !bVersioned) return 1;
const lengthDiff = b.method.length - a.method.length;
if (lengthDiff !== 0) return lengthDiff;
if (a.fileSlug.includes('.v2.') && !b.fileSlug.includes('.v2.')) return -1;
if (!a.fileSlug.includes('.v2.') && b.fileSlug.includes('.v2.')) return 1;
return 0;
});
let found = null;
// Manual overrides for UserService "ghost" methods
if (mappedService === 'user') {
if (parsed.methodSlug === 'user-service-create-user') {
found = candidates.find(c => c.method === 'AddHumanUser');
} else if (parsed.methodSlug === 'user-service-update-user') {
found = candidates.find(c => c.method === 'UpdateHumanUser');
}
}
if (!found) {
for (const cand of candidates) {
const candKebab = cand.method.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
if (parsed.methodSlug === candKebab || parsed.methodSlug.endsWith('-' + candKebab)) {
found = cand;
break;
}
}
}
if (found) {
redirects.push({
source: u.replace('https://zitadel.com/docs', ''),
destination: found.full.replace('https://zitadel.com/docs', ''),
permanent: true
});
} else {
missing.push(u);
}
}
console.log(`Generated ${redirects.length} redirects.`);
console.log(`Missing ${missing.length} URLs.`);
fs.writeFileSync(path.join(ROOT_DIR, 'redirects.json'), JSON.stringify(redirects, null, 2));
fs.writeFileSync(path.join(ROOT_DIR, 'missing.txt'), missing.join('\n'));
}
run();
+71
View File
@@ -0,0 +1,71 @@
import fs from 'fs';
import path from 'path';
const CONTENT_DIR = 'content';
function walk(dir, callback) {
fs.readdirSync(dir).forEach((file) => {
const filepath = path.join(dir, file);
const stats = fs.statSync(filepath);
if (stats.isDirectory()) {
walk(filepath, callback);
} else if (stats.isFile() && (filepath.endsWith('.mdx') || filepath.endsWith('.md'))) {
callback(filepath);
}
});
}
walk(CONTENT_DIR, (filepath) => {
let content = fs.readFileSync(filepath, 'utf8');
let changed = false;
const segments = filepath.split(path.sep);
const depth = segments.length - 1;
const correctPrefix = '../'.repeat(depth) + 'public/';
// Match any sequence of ../ followed by public/
const relativeLinkRegex = /(\.\.\/)+public\//g;
if (relativeLinkRegex.test(content)) {
const newContent = content.replace(relativeLinkRegex, correctPrefix);
if (newContent !== content) {
content = newContent;
changed = true;
}
}
// Also catch any remaining absolute ones if they exist
// Regex to match Markdown images: ![alt](/docs/path...)
// and HTML images: src="/docs/path..."
// Capturing group 1: '![...](', 2: path
// OR src=", 2: path
// Replaces /docs/img/ -> correctPrefix + img/
// Replaces /docs/vX.Y/ -> correctPrefix + vX.Y/
const PUBLIC_PREFIX = '/docs/img/';
const VERSION_PREFIXES = ['/docs/v4.10/', '/docs/v4.9/', '/docs/v4.8/'];
const TARGET_PATHS = [PUBLIC_PREFIX, ...VERSION_PREFIXES];
// Pattern: (!\[.*?\]\(|src=["'])(/docs/(?:img|v\d+\.\d+)/.*?)(\)|["'])
const pattern = /(!\[.*?\]\(|src=["'])\/docs\/((?:img|v\d+\.\d+)\/.*?)(\)|["'])/g;
if (pattern.test(content)) {
const newContent = content.replace(pattern, (match, prefix, pathPart, suffix) => {
// prefix e.g. '![alt](' or 'src="'
// pathPart e.g. 'img/foo.png' or 'v4.10/foo.png'
// suffix e.g. ')' or '"'
return `${prefix}${correctPrefix}${pathPart}${suffix}`;
});
if (newContent !== content) {
content = newContent;
changed = true;
}
}
if (changed) {
fs.writeFileSync(filepath, content);
console.log(`Updated: ${filepath}`);
}
});
+12
View File
@@ -0,0 +1,12 @@
import { glob } from 'glob';
import { resolve } from 'node:path';
const CONTENT_ROOT = resolve('content');
async function test() {
const files = await glob('**/*.mdx', { cwd: CONTENT_ROOT });
console.log(`Found ${files.length} files`);
if (files.length > 0) {
console.log(`Sample: ${files[0]}`);
}
}
test();
+91
View File
@@ -0,0 +1,91 @@
import fs from 'fs';
import path from 'path';
const REDIRECTS_PATH = '/home/ffo/git/zitadel/zitadel/apps/docs/redirects.json';
function validateRedirects() {
const content = fs.readFileSync(REDIRECTS_PATH, 'utf8');
const redirects = JSON.parse(content);
const sources = new Map();
const inconsistencies = [];
// 1. Basic Format & Duplicates & Self-Redirects
redirects.forEach((r, index) => {
if (!r.source || !r.destination) {
inconsistencies.push({ type: 'MISSING_FIELDS', index, entry: r });
return;
}
if (!r.source.startsWith('/')) {
inconsistencies.push({ type: 'SOURCE_MISSING_SLASH', index, source: r.source });
}
if (r.destination.startsWith('/docs/') && r.basePath !== false) {
inconsistencies.push({ type: 'DESTINATION_HAS_DOCS_PREFIX', index, source: r.source, destination: r.destination });
}
if (r.source === r.destination) {
inconsistencies.push({ type: 'SELF_REDIRECT', index, source: r.source });
}
if (sources.has(r.source)) {
inconsistencies.push({ type: 'DUPLICATE_SOURCE', index, source: r.source, previousIndex: sources.get(r.source).index });
} else {
sources.set(r.source, { destination: r.destination, index });
}
});
// 2. Circular & Chains
const checked = new Set();
sources.forEach((value, source) => {
let currentSource = source;
const path = [currentSource];
const visitedInCurrentPath = new Set([currentSource]);
while (sources.has(sources.get(currentSource)?.destination)) {
const nextDestination = sources.get(currentSource).destination;
// Normalize for comparison if one has /docs and other doesn't
// But for now let's just use exact match since they are all consistent
if (visitedInCurrentPath.has(nextDestination)) {
inconsistencies.push({ type: 'CIRCULAR_REDIRECT', path: [...path, nextDestination] });
break;
}
path.push(nextDestination);
visitedInCurrentPath.add(nextDestination);
currentSource = nextDestination;
}
if (path.length > 2) {
inconsistencies.push({ type: 'REDIRECT_CHAIN', path });
}
});
// Deduplicate inconsistencies
const uniqueInconsistencies = [];
const seen = new Set();
const counts = {};
inconsistencies.forEach(inc => {
counts[inc.type] = (counts[inc.type] || 0) + 1;
const key = JSON.stringify(inc);
if (!seen.has(key)) {
uniqueInconsistencies.push(inc);
seen.add(key);
}
});
console.log('Inconsistency Counts:', JSON.stringify(counts, null, 2));
const others = uniqueInconsistencies.filter(inc => inc.type !== 'DESTINATION_HAS_DOCS_PREFIX');
if (others.length > 0) {
console.log('Other Inconsistencies:', JSON.stringify(others, null, 2));
} else {
console.log('No other inconsistencies found.');
}
}
validateRedirects();