Files
vercel__workflow/docs/scripts/lint.ts
Peter Wielander 5188f5b003 docs: make v5 the default documentation version
Serves `content/docs/v5` and `content/worlds/v5` unprefixed at /docs,
/worlds and /cookbook, moves v4 under /v4, and puts What's new first in
the v5 sidebar.

The versioned source config drives both route trees (the root tree always
renders `versionedSources.current`), so this is a `routePrefix` move plus
source re-binding rather than a restructure. The only file moves are
`app/[lang]/v5/**` → `app/[lang]/v4/**`.

- Version switcher: `v5 (Latest)` / `v4 (Maintenance)`, `current: 'v5'`.
- `pre-release-banner.tsx` becomes `maintenance-banner.tsx`. v4 pages
  carry an amber notice whose "Go to Workflow 5 (Latest)" link deep-links
  to the same page on the current version, falling back to the nearest
  section index for v4-only pages, and keep `robots: noindex, follow`.
- Redirects: `/v5/*` to the unprefixed equivalent (bare `/v5` needs its
  own rule, since `:path*` expands to an empty Location). The world-docs
  and api-reference restructure rules are mirrored onto `/v4/docs/*`, and
  every page existing in only one tree gets a version-switcher fallback.
- Both worlds route trees pass an explicit version into the shared page
  components, whose semantics flipped with the switch, so the smoke checks
  now assert the pairing: a " · v4" title marker and noindex on the
  maintenance routes, neither on the canonical ones, and a community world
  serving directly rather than self-redirecting.
- The link lint's two URL spaces swap with the prefixes. Redirect
  destinations resolve against the real HTTP space, since redirects are
  matched before render-time href rewriting.
- The two `/v4/...` links this makes expressible are restored: a v5 page
  cannot link to a v4 page while v4 is the unprefixed version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:50:56 -07:00

636 lines
19 KiB
TypeScript
Executable File

import { readdir, readFile, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Folder, Node, Root } from 'fumadocs-core/page-tree';
import GithubSlugger from 'github-slugger';
import {
type FileObject,
printErrors,
validateFiles,
} from 'next-validate-link';
import {
getDocsTreeWithoutCookbook,
rewriteCookbookUrl,
} from '../lib/geistdocs/cookbook-source';
import { resolveSectionChildren } from '../lib/geistdocs/section-children';
import {
source,
v4Source,
v4WorldsSource,
worldsSource,
} from '../lib/geistdocs/source';
import { getWorldIds } from '../lib/worlds-data';
import nextConfig from '../next.config';
const DOCS_DIR = fileURLToPath(new URL('..', import.meta.url));
const STATIC_APP_LINK_FILES = [
'geistdocs.tsx',
'app/[lang]/(home)/components/templates/index.tsx',
'app/[lang]/worlds/page.tsx',
];
const KNOWN_APP_PATHS = new Set(['/', '/docs', '/cookbook', '/worlds']);
type UrlMeta = { hashes?: string[] };
type Scanned = {
urls: Map<string, UrlMeta>;
fallbackUrls: { url: RegExp; meta: UrlMeta }[];
};
// The geistdocs source bundle erases the collection's frontmatter/runtime
// typing, so restore the fields the lint relies on structurally.
type RawDocsPage = ReturnType<typeof source.getPages>[number] & {
absolutePath: string;
data: ReturnType<typeof source.getPages>[number]['data'] & {
getText(kind: 'raw'): Promise<string>;
};
};
type LoadedPage = {
page: RawDocsPage;
raw: string;
hashes: string[];
};
async function loadPages(src: typeof source): Promise<LoadedPage[]> {
return Promise.all(
(src.getPages() as RawDocsPage[]).map(async (page) => {
const raw = await page.data.getText('raw');
return { page, raw, hashes: getHeadingsFromMarkdown(raw) };
})
);
}
/**
* Static (non-fumadocs) routes that exist in the app for both versions:
* the home page, section landing pages, worlds detail pages, and files
* served from public/.
*/
async function getSharedUrls(): Promise<Map<string, UrlMeta>> {
const urls = new Map<string, UrlMeta>();
for (const path of [
'/',
'/docs',
'/v4/docs',
'/cookbook',
'/v4/cookbook',
'/worlds',
'/worlds/compare',
'/llms.txt',
'/sitemap.md',
]) {
urls.set(path, {});
}
for (const id of getWorldIds()) {
urls.set(`/worlds/${id}`, {});
}
for (const asset of await listFilesRecursive(join(DOCS_DIR, 'public'))) {
urls.set(`/${asset}`, {});
}
return urls;
}
async function listFilesRecursive(dir: string, prefix = ''): Promise<string[]> {
const out: string[] = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
out.push(...(await listFilesRecursive(join(dir, entry.name), rel)));
} else {
out.push(rel);
}
}
return out;
}
/**
* Build the two URL spaces links are resolved against.
*
* v5 space — how hrefs resolve when rendered on a v5 (unversioned, current)
* page:
* /docs/X → v5 page X
* /cookbook/X → v5 cookbook page
* /v4/docs/X → v4 page X (explicit cross-version link)
* /v4/cookbook/X → v4 cookbook page
*
* v4 space — how hrefs resolve when rendered on a /v4 page. The v4 routes
* rewrite /docs/... hrefs (inline links and Card hrefs) to /v4/docs/... at
* render time, so an unversioned /docs/X href on a v4 page resolves to the
* v4 page X — it is broken unless X exists in the v4 content tree:
* /docs/X → v4 page X (rewritten at render time)
* /cookbook/X → v5 cookbook page (not rewritten)
* /v4/docs/X → v4 page X
* /v4/cookbook/X → v4 cookbook page
*/
function buildSpaces(
v4Pages: LoadedPage[],
v5Pages: LoadedPage[],
worldsV4Pages: LoadedPage[],
worldsV5Pages: LoadedPage[],
shared: Map<string, UrlMeta>
): { v4Space: Scanned; v5Space: Scanned } {
const v4Space: Scanned = { urls: new Map(shared), fallbackUrls: [] };
const v5Space: Scanned = { urls: new Map(shared), fallbackUrls: [] };
// World docs are versioned like the docs trees and rendered at /worlds/*
// (v5/current) and /v4/worlds/* (maintenance). They follow the same URL
// resolution model: on v4 pages, unversioned /worlds/... hrefs are
// rewritten to /v4/worlds/... at render time. Unlike the manifest-derived
// entries in getSharedUrls, these carry heading hashes.
for (const { page, hashes } of worldsV5Pages) {
v5Space.urls.set(page.url, { hashes });
}
for (const { page, hashes } of worldsV4Pages) {
const meta = { hashes };
v5Space.urls.set(`/v4${page.url}`, meta);
v4Space.urls.set(`/v4${page.url}`, meta);
v4Space.urls.set(page.url, meta);
}
for (const { page, hashes } of v5Pages) {
const meta = { hashes };
v5Space.urls.set(page.url, meta);
const cookbookUrl = rewriteCookbookUrl(page.url);
if (cookbookUrl !== page.url) {
// /docs/cookbook/X is served at /cookbook/X — valid in both spaces
// (cookbook links are not version-rewritten on v4 pages).
v5Space.urls.set(cookbookUrl, meta);
v4Space.urls.set(cookbookUrl, meta);
}
}
for (const { page, hashes } of v4Pages) {
const meta = { hashes };
v5Space.urls.set(`/v4${page.url}`, meta);
v4Space.urls.set(`/v4${page.url}`, meta);
// On v4 pages, unversioned /docs/... hrefs are rewritten to /v4/docs/...
// at render time, so they resolve to the v4 page.
v4Space.urls.set(page.url, meta);
const cookbookUrl = rewriteCookbookUrl(page.url);
if (cookbookUrl !== page.url) {
v5Space.urls.set(`/v4${cookbookUrl}`, meta);
v4Space.urls.set(`/v4${cookbookUrl}`, meta);
}
}
return { v4Space, v5Space };
}
/**
* Mark next.config.ts redirect sources as valid wherever their destination
* is valid. Parameterized sources (/a/:path*) are expanded against the
* concrete URLs already in the space, so a redirect never blanket-validates
* URLs whose destination doesn't exist.
*
* Destinations are resolved against the v5 space, which is the real HTTP URL
* space (unprefixed URLs are served by the current version, /v4 ones by the
* maintenance version). Redirects are matched by the server before a page
* renders, so the render-time /docs → /v4/docs href rewriting never applies
* to them — a /v5/... link on a v4 page still lands on the v5 page.
*
* Sources under /docs are only reachable from v5 pages (on v4 pages the
* render-time rewrite turns /docs/... into /v4/docs/..., which skips the
* redirect), so they are only added to the v5 space.
*/
async function applyRedirects(v4Space: Scanned, v5Space: Scanned) {
const redirects = (await nextConfig.redirects?.()) ?? [];
for (const { source: src, destination: dest } of redirects) {
if (src.includes(':') !== dest.includes(':')) continue;
const spaces =
src.startsWith('/docs') && !src.startsWith('/docs/cookbook')
? [v5Space]
: [v4Space, v5Space];
for (const space of spaces) {
applyRedirectToSpace(space, src, dest, v5Space);
}
}
}
function applyRedirectToSpace(
space: Scanned,
src: string,
dest: string,
httpSpace: Scanned
) {
if (!src.includes(':')) {
const meta = httpSpace.urls.get(dest);
if (meta) space.urls.set(src, meta);
return;
}
// Parameterized: both source and destination are a static prefix followed
// by the same parameter (e.g. /docs/cookbook/:path* → /cookbook/:path*).
// Expand by swapping prefixes against known URLs.
const srcPrefix = src.slice(0, src.indexOf('/:'));
const destPrefix = dest.slice(0, dest.indexOf('/:'));
for (const [url, meta] of [...httpSpace.urls]) {
if (url.startsWith(`${destPrefix}/`)) {
space.urls.set(srcPrefix + url.slice(destPrefix.length), meta);
}
}
}
function getHeadingsFromMarkdown(content: string): string[] {
const slugger = new GithubSlugger();
const headingRegex = /^#{1,6}\s+(.+)$/gm;
const headings: string[] = [];
let match = headingRegex.exec(content);
while (match !== null) {
const headingText = match[1].trim();
headings.push(slugger.slug(headingText));
match = headingRegex.exec(content);
}
return headings;
}
function toFileObjects(pages: LoadedPage[]): FileObject[] {
return pages.map(({ page, raw }) => ({
path: page.absolutePath,
content: raw,
url: page.url,
data: page.data,
}));
}
/**
* Extract `related` and `prerequisites` list entries from a page's raw
* frontmatter.
*/
function getFrontmatterRefs(raw: string): string[] {
const frontmatter = raw.match(/^---\n([\s\S]*?)\n---/)?.[1];
if (!frontmatter) return [];
const refs: string[] = [];
let inRefBlock = false;
for (const line of frontmatter.split('\n')) {
if (/^(related|prerequisites):\s*$/.test(line)) {
inRefBlock = true;
continue;
}
const item = inRefBlock && line.match(/^\s+-\s+(\/\S+)\s*$/);
if (item) {
refs.push(item[1]);
} else if (!/^\s/.test(line)) {
inRefBlock = false;
}
}
return refs;
}
/**
* Validate frontmatter `related` and `prerequisites` references. These are
* version-relative: a /docs/... reference on a v4 page must exist in the v4
* content tree (matching how the page's links resolve when rendered).
*/
function checkFrontmatterRefs(
pages: LoadedPage[],
space: Scanned,
errors: { href: string; reason: string; sourcePath: string }[]
) {
for (const { page, raw } of pages) {
for (const ref of getFrontmatterRefs(raw)) {
const [pathname, fragment] = ref.split('#', 2);
const meta = space.urls.get(pathname.replace(/\/$/, '') || '/');
if (!meta) {
errors.push({
href: ref,
sourcePath: page.absolutePath,
reason: 'not found',
});
} else if (fragment && meta.hashes && !meta.hashes.includes(fragment)) {
errors.push({
href: ref,
sourcePath: page.absolutePath,
reason: `heading #${fragment} not found`,
});
}
}
}
}
async function checkLinks() {
const [v4Pages, v5Pages, worldsV4Pages, worldsV5Pages, shared] =
await Promise.all([
loadPages(v4Source),
loadPages(source),
loadPages(v4WorldsSource),
loadPages(worldsSource),
getSharedUrls(),
]);
const { v4Space, v5Space } = buildSpaces(
v4Pages,
v5Pages,
worldsV4Pages,
worldsV5Pages,
shared
);
await applyRedirects(v4Space, v5Space);
const markdown = {
components: {
Card: { attributes: ['href'] },
},
};
const [v4Errors, v5Errors, worldsV4Errors, worldsV5Errors] =
await Promise.all([
validateFiles(toFileObjects(v4Pages), {
scanned: v4Space,
markdown,
checkRelativePaths: 'as-url',
}),
validateFiles(toFileObjects(v5Pages), {
scanned: v5Space,
markdown,
checkRelativePaths: 'as-url',
}),
// World pages resolve links version-relative, exactly like docs pages:
// v5 world pages against the v5 space, v4 world pages against the v4
// space (where unversioned /docs and /worlds hrefs are render-rewritten
// into the /v4 view).
validateFiles(toFileObjects(worldsV4Pages), {
scanned: v4Space,
markdown,
checkRelativePaths: 'as-url',
}),
validateFiles(toFileObjects(worldsV5Pages), {
scanned: v5Space,
markdown,
checkRelativePaths: 'as-url',
}),
]);
printErrors(
[...v4Errors, ...v5Errors, ...worldsV4Errors, ...worldsV5Errors],
true
);
const frontmatterErrors: {
href: string;
reason: string;
sourcePath: string;
}[] = [];
checkFrontmatterRefs(v4Pages, v4Space, frontmatterErrors);
checkFrontmatterRefs(v5Pages, v5Space, frontmatterErrors);
checkFrontmatterRefs(worldsV4Pages, v4Space, frontmatterErrors);
checkFrontmatterRefs(worldsV5Pages, v5Space, frontmatterErrors);
if (frontmatterErrors.length > 0) {
console.error('\nBroken frontmatter references:');
for (const error of frontmatterErrors) {
console.error(`- ${error.sourcePath} -> ${error.href}: ${error.reason}`);
}
process.exitCode = 1;
}
await checkStaticAppLinks();
checkSectionCards(v4Pages, v5Pages);
await checkMetaEntriesResolve();
}
/**
* Enforce that every section landing page accounts for all of its navigation
* children. A page passes if it either:
* - renders `<AutoCards />` (cards are derived from the tree — drift is
* structurally impossible), or
* - declares `manualCards: true` in frontmatter (intentionally curated), or
* - has a `<Card href>` for every child page in the section.
*
* The existing link validation already covers the reverse direction (cards that
* point at pages which don't exist), so together the two checks keep the card
* grid and the sidebar in lockstep.
*/
function sectionMissingCards(
folder: Folder,
tree: Root,
rawByUrl: Map<string, LoadedPage>
): { sourcePath: string; missing: string[] } | null {
const sectionUrl = folder.index?.url;
if (!sectionUrl) return null;
const expected = resolveSectionChildren(tree, sectionUrl).map((c) =>
normalizePathname(c.url)
);
if (expected.length === 0) return null;
const loaded = rawByUrl.get(sectionUrl);
if (!loaded) return null;
const { raw, page } = loaded;
// Drift is impossible (AutoCards) or intentionally owned by the author.
if (/<AutoCards\b/.test(raw) || hasManualCardsFlag(raw)) return null;
const carded = new Set(
getCardHrefs(raw).map((href) => normalizePathname(href))
);
const missing = expected.filter((url) => !carded.has(url));
return missing.length > 0 ? { sourcePath: page.absolutePath, missing } : null;
}
function checkSectionCards(v4Pages: LoadedPage[], v5Pages: LoadedPage[]) {
const errors: { sourcePath: string; missing: string[] }[] = [];
for (const [pages, version] of [
[v4Pages, 'v4'],
[v5Pages, 'v5'],
] as const) {
const tree = getDocsTreeWithoutCookbook('en', version);
const rawByUrl = new Map(pages.map((p) => [p.page.url, p]));
for (const folder of collectSectionFolders(tree.children)) {
const result = sectionMissingCards(folder, tree, rawByUrl);
if (result) errors.push(result);
}
}
if (errors.length > 0) {
console.error(
'\nSection landing pages missing cards for navigation children:'
);
for (const error of errors) {
console.error(`- ${error.sourcePath}: ${error.missing.join(', ')}`);
}
console.error(
' Fix by using <AutoCards /> (recommended), adding the missing <Card> ' +
'entries, or setting `manualCards: true` if the page is intentionally curated.'
);
process.exitCode = 1;
}
}
/** Recursively collect folders that have an index page (section landing pages). */
function collectSectionFolders(nodes: Node[]): Folder[] {
const folders: Folder[] = [];
for (const node of nodes) {
if (node.type !== 'folder') continue;
if (node.index) folders.push(node);
folders.push(...collectSectionFolders(node.children));
}
return folders;
}
function getCardHrefs(raw: string): string[] {
const hrefs: string[] = [];
const pattern = /<Card\b[^>]*?\bhref="([^"]+)"/g;
let match = pattern.exec(raw);
while (match !== null) {
hrefs.push(match[1].split('#')[0]);
match = pattern.exec(raw);
}
return hrefs;
}
function hasManualCardsFlag(raw: string): boolean {
const frontmatter = raw.match(/^---\n([\s\S]*?)\n---/)?.[1];
return frontmatter ? /^manualCards:\s*true\s*$/m.test(frontmatter) : false;
}
/**
* Validate that every plain-slug `pages` entry in a `meta.json` resolves to a
* real page file or sub-folder. Catches dangling navigation entries (e.g. a
* `cancellation` entry with no `cancellation.mdx`). Fumadocs control tokens
* (`...rest`, `---separators---`, `[label](url)` links, `!exclude`) are skipped.
*/
// Fumadocs control tokens that don't reference a page: rest globs, separators,
// external links, and exclusions.
function isMetaControlToken(entry: string): boolean {
return (
entry === 'index' ||
entry.startsWith('...') ||
entry.startsWith('---') ||
entry.startsWith('[') ||
entry.startsWith('!')
);
}
async function metaEntryResolves(dir: string, entry: string): Promise<boolean> {
return (
(await pathExists(join(dir, `${entry}.mdx`))) ||
(await pathExists(join(dir, `${entry}.md`))) ||
(await pathExists(join(dir, entry)))
);
}
async function unresolvedMetaEntries(metaPath: string): Promise<string[]> {
let pages: unknown;
try {
pages = JSON.parse(await readFile(metaPath, 'utf8')).pages;
} catch {
return [];
}
if (!Array.isArray(pages)) return [];
const dir = metaPath.slice(0, metaPath.lastIndexOf('/'));
const unresolved: string[] = [];
for (const entry of pages) {
if (typeof entry !== 'string' || isMetaControlToken(entry)) continue;
if (!(await metaEntryResolves(dir, entry))) unresolved.push(entry);
}
return unresolved;
}
async function checkMetaEntriesResolve() {
const errors: { sourcePath: string; entry: string }[] = [];
const contentRoots = [
join(DOCS_DIR, 'content/docs'),
join(DOCS_DIR, 'content/worlds'),
];
for (const contentRoot of contentRoots) {
for (const metaPath of await findFiles(contentRoot, 'meta.json')) {
for (const entry of await unresolvedMetaEntries(metaPath)) {
errors.push({ sourcePath: metaPath, entry });
}
}
}
if (errors.length > 0) {
console.error('\nmeta.json entries with no matching page or folder:');
for (const error of errors) {
console.error(`- ${error.sourcePath} -> "${error.entry}"`);
}
process.exitCode = 1;
}
}
async function findFiles(dir: string, name: string): Promise<string[]> {
const out: string[] = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...(await findFiles(full, name)));
} else if (entry.name === name) {
out.push(full);
}
}
return out;
}
async function pathExists(p: string): Promise<boolean> {
try {
await stat(p);
return true;
} catch {
return false;
}
}
async function checkStaticAppLinks() {
const errors: { href: string; reason: string; sourcePath: string }[] = [];
for (const sourcePath of STATIC_APP_LINK_FILES) {
const content = await readFile(join(DOCS_DIR, sourcePath), 'utf8');
for (const href of getInternalHrefLiterals(content)) {
if (!isKnownInternalPath(href)) {
errors.push({
href,
sourcePath,
reason: 'no matching docs source page or app route',
});
}
}
}
if (errors.length > 0) {
console.error('\nBroken app source links:');
for (const error of errors) {
console.error(`- ${error.sourcePath} -> ${error.href}: ${error.reason}`);
}
process.exitCode = 1;
}
}
function getInternalHrefLiterals(content: string): string[] {
const hrefs: string[] = [];
const hrefPattern =
/\b(?:href|link)\s*(?:=|:)\s*(['"`])(\/(?!\/)[^'"`]*?)\1/g;
let match = hrefPattern.exec(content);
while (match !== null) {
hrefs.push(match[2]);
match = hrefPattern.exec(content);
}
return hrefs;
}
function isKnownInternalPath(href: string) {
const url = new URL(href, 'https://workflow-sdk.dev');
const pathname = normalizePathname(url.pathname);
return (
KNOWN_APP_PATHS.has(pathname) ||
source.getPageByHref(pathname) !== undefined ||
worldsSource.getPageByHref(pathname) !== undefined
);
}
function normalizePathname(pathname: string) {
return pathname.replace(/\/$/, '') || '/';
}
void checkLinks();