mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-19 22:14:25 +00:00
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:
co-authored by
Federico Coppede
parent
7b6395c348
commit
d8ea041c56
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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: 
|
||||
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);
|
||||
});
|
||||
@@ -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 ---');
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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: 
|
||||
// and HTML images: src="/docs/path..."
|
||||
// Capturing group 1: '(/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. '' or '"'
|
||||
return `${prefix}${correctPrefix}${pathPart}${suffix}`;
|
||||
});
|
||||
|
||||
if (newContent !== content) {
|
||||
content = newContent;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(filepath, content);
|
||||
console.log(`Updated: ${filepath}`);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user