Files
zitadel/apps/docs/scripts/generate-index-pages.ts
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

236 lines
6.7 KiB
TypeScript

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