From 27d82d8cfbc9ea5ed3294e916d5cc652e23fa3d0 Mon Sep 17 00:00:00 2001 From: DABH Date: Thu, 20 Aug 2026 22:52:20 -0500 Subject: [PATCH 1/5] WIP: prototype insert-only sidebar link list generator for #5144 --- bin/generate-sidebar-link-lists.js | 400 ++++++++++++++++++++++++ bin/generate-sidebar-link-lists.test.js | 118 +++++++ docs/develop/python/workers/index.mdx | 3 + package.json | 2 + 4 files changed, 523 insertions(+) create mode 100644 bin/generate-sidebar-link-lists.js create mode 100644 bin/generate-sidebar-link-lists.test.js diff --git a/bin/generate-sidebar-link-lists.js b/bin/generate-sidebar-link-lists.js new file mode 100644 index 0000000000..fbe6eba230 --- /dev/null +++ b/bin/generate-sidebar-link-lists.js @@ -0,0 +1,400 @@ +#!/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; +} + +function findNamedDescendant(category, label) { + let found = null; + eachCategory(category.children, (cat) => { + if (!found && cat.label === label) found = cat; + }); + return found; +} + +// --------------------------------------------------------------------------- +// 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*\[(?