mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 13:52:51 +00:00
## 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]>
287 lines
9.3 KiB
TypeScript
287 lines
9.3 KiB
TypeScript
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);
|
|
}
|