diff --git a/bin/generate-sidebar-link-lists.js b/bin/generate-sidebar-link-lists.js new file mode 100644 index 0000000000..64175a2a7f --- /dev/null +++ b/bin/generate-sidebar-link-lists.js @@ -0,0 +1,475 @@ +#!/usr/bin/env node + +// Generates the "here's what's in this section" link lists that SDK landing +// pages and section index pages carry, from sidebars.js — the same source +// Docusaurus builds the left nav from. +// +// Those lists are hand-maintained today, which is why they drift: adding a +// page wires it into sidebars.js but nothing updates the prose lists that +// duplicate it. See https://github.com/temporalio/documentation/issues/5144. +// +// Only regions wrapped in markers are touched, so a page opts in per list and +// everything around the markers stays hand-written: +// +// {/* SIDEBAR-LIST-START */} +// - [Workflow basics](/develop/python/workflows/basics) +// {/* SIDEBAR-LIST-END */} +// +// With no attribute the region renders the direct children of whichever +// sidebar category links to *this* page — the section-index-page case. A +// landing page carries several lists, one per section, so those name it: +// +// {/* SIDEBAR-LIST-START section="Workflows" */} +// +// Markers are MDX expression comments, so the LLM markdown pipeline +// (scripts/mdx-to-md.mjs) already strips them while the generated bullets +// survive as ordinary Markdown. That's the reason for generating text in +// place rather than rendering a React component: a component would need a +// parallel handler in scripts/component-handlers/ to avoid blanking these +// links in llms-full.txt and the per-page .md files, the way an unwired +// currently blanks the guides catalog. +// +// node bin/generate-sidebar-link-lists.js # rewrite in place +// node bin/generate-sidebar-link-lists.js --check # CI: fail if stale +// node bin/generate-sidebar-link-lists.js --json # machine-readable +// node bin/generate-sidebar-link-lists.js --list # marker inventory +// +// Exit codes: 0 clean, 2 stale regions (--check) or an unresolvable marker. + +const fs = require('fs'); +const path = require('path'); +const matter = require('gray-matter'); + +const { walkDir, resolveUrlPath } = require('../plugins/shared/docsRouting'); +const { isExcludedDocPath } = require('./check-orphan-pages'); + +const DOCS_DIR = path.join(process.cwd(), 'docs'); +const SIDEBARS_FILE = path.join(process.cwd(), 'sidebars.js'); + +const START = /^(\s*)\{\/\*\s*SIDEBAR-LIST-START(?[^*]*?)\*\/\}\s*$/; +const END = /^\s*\{\/\*\s*SIDEBAR-LIST-END\s*\*\/\}\s*$/; + +// --------------------------------------------------------------------------- +// Doc index: every routed doc file, keyed by the doc id sidebars.js uses +// --------------------------------------------------------------------------- + +// Mirrors bin/check-orphan-pages.js's computeDocId: an index file's id keeps +// the literal "index" segment, because that's how sidebars.js addresses it. +function computeDocId(filePath) { + const rel = path.relative(DOCS_DIR, filePath).replace(/\\/g, '/'); + const withoutExt = rel.replace(/\.(md|mdx)$/i, ''); + const dir = path.dirname(withoutExt); + const base = path.basename(withoutExt); + return dir === '.' ? base : `${dir}/${base}`; +} + +function buildDocIndex() { + const byId = new Map(); + for (const filePath of walkDir(DOCS_DIR)) { + const rel = path.relative(DOCS_DIR, filePath); + if (isExcludedDocPath(rel)) continue; + + const { data } = matter(fs.readFileSync(filePath, 'utf8')); + const id = data.id && !data.id.includes('/') + // A frontmatter `id` renames only the last segment; sidebars.js still + // addresses the file by its directory path. + ? [path.dirname(computeDocId(filePath)), data.id].filter((s) => s !== '.').join('/') + : computeDocId(filePath); + + byId.set(id, { + filePath, + url: `/${resolveUrlPath(DOCS_DIR, filePath, data)}`, + // Docusaurus falls back title -> sidebar_label for nav text; a `label` + // in sidebars.js overrides both and is applied by resolveItem below. + label: data.sidebar_label || data.title || path.basename(id), + draft: data.draft === true, + unlisted: data.unlisted === true, + }); + } + return byId; +} + +// --------------------------------------------------------------------------- +// Sidebar tree, resolved to {label, url} the way the rendered nav resolves it +// --------------------------------------------------------------------------- + +function resolveItem(item, docs) { + if (typeof item === 'string') { + const doc = docs.get(item); + return doc ? { kind: 'doc', id: item, label: doc.label, url: doc.url } : null; + } + if (!item || typeof item !== 'object') return null; + + if (item.type === 'doc' && item.id) { + const doc = docs.get(item.id); + if (!doc) return null; + return { kind: 'doc', id: item.id, label: item.label || doc.label, url: doc.url }; + } + if (item.type === 'link' && item.href) { + return { kind: 'link', label: item.label, url: item.href, external: true }; + } + if (item.type === 'category') { + const linkId = item.link && item.link.type === 'doc' ? item.link.id : item.link && item.link.id; + const doc = linkId ? docs.get(linkId) : null; + return { + kind: 'category', + label: item.label, + url: doc ? doc.url : null, + linkId: linkId || null, + children: (item.items || []).map((child) => resolveItem(child, docs)).filter(Boolean), + }; + } + return null; +} + +function buildSidebarTree(docs) { + // sidebars.js is plain CommonJS and already require()s src/constants, so + // requiring it gives the real config rather than a regex approximation. + const sidebars = require(SIDEBARS_FILE); + return Object.values(sidebars) + .flat() + .map((item) => resolveItem(item, docs)) + .filter(Boolean); +} + +function eachCategory(nodes, visit) { + for (const node of nodes) { + if (node.kind !== 'category') continue; + visit(node); + eachCategory(node.children, visit); + } +} + +// The category a page "owns" is the one whose link points at that page. +function findOwningCategory(tree, docId) { + let found = null; + eachCategory(tree, (cat) => { + if (!found && cat.linkId === docId) found = cat; + }); + return found; +} + +// Matches a sidebar label or the category's own URL. The URL form exists +// because a landing page's heading text often isn't the sidebar label — +// "## [Temporal Client](/develop/python/client)" sits above the category +// labelled "Client" — and the heading already carries the URL, so an author +// can copy it rather than having to go read sidebars.js. +function findNamedDescendant(category, nameOrUrl) { + const wanted = String(nameOrUrl).replace(/\/$/, ''); + const byUrl = []; + const byLabel = []; + eachCategory(category.children, (cat) => { + if (cat.url && cat.url.replace(/\/$/, '') === wanted) byUrl.push(cat); + else if (cat.label === nameOrUrl) byLabel.push(cat); + }); + + // A URL is unique, so prefer it and don't let a same-named category elsewhere + // in the tree interfere. + if (byUrl.length) return byUrl[0]; + + // Labels are not unique — "Quickstart" appears under several sections — so + // refuse rather than silently picking whichever came first in traversal. + if (byLabel.length > 1) { + const urls = byLabel.map((c) => c.url || '(no link)').join(', '); + throw new Error(`"${nameOrUrl}" matches ${byLabel.length} categories under "${category.label}" (${urls}); use the category URL instead of its label`); + } + return byLabel[0] || null; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +// A category may have no `link` of its own (Standalone Activities, for one). +// Docusaurus renders those by falling back to the first descendant that does +// have a link — mirror that instead of dropping the entry, so a category can +// never silently vanish from a generated list. +function firstLink(node) { + if (node.url) return node.url; + for (const child of node.children || []) { + const url = firstLink(child); + if (url) return url; + } + return null; +} + +// One level only, matching what the pages express today: a nested category +// renders as a single link rather than being flattened, which is what keeps +// depth-4 pages (serverless-workers/*, data-handling/*) off the landing pages. +function renderList(category, indent) { + return category.children.map((child) => { + const url = firstLink(child); + if (!url) { + // Refusing here rather than omitting: a silently shorter list is the + // failure mode this script exists to prevent. + throw new Error(`sidebar item "${child.label}" under "${category.label}" has no resolvable link, so it cannot be rendered`); + } + return `${indent}- [${child.label}](${url})`; + }); +} + +const BULLET = /^\s*-\s*\[(?