Files
zitadel/apps/docs/scripts/generate-api-reference.mts
T
d8ea041c56 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]>
2026-01-29 08:38:36 -08:00

242 lines
7.9 KiB
TypeScript

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);
});