import fs from 'node:fs/promises' import path from 'node:path' import { parsePartialVariables, substitutePartialVars } from '~/lib/partials.utils' import matter from 'gray-matter' import type { Content, Parent, Root } from 'mdast' import { fromMarkdown } from 'mdast-util-from-markdown' import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm' import { mdxFromMarkdown, mdxToMarkdown } from 'mdast-util-mdx' import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx' import { toMarkdown } from 'mdast-util-to-markdown' import { gfm } from 'micromark-extension-gfm' import { mdxjs } from 'micromark-extension-mdxjs' import { parse as parseToml } from 'smol-toml' import { mcpConfigPanelMarkdown as McpConfigPanel } from 'ui-patterns/McpUrlBuilder/McpConfigPanel.md' import { addBaseUrlPrefix, getInternalLinkBaseUrl, withDocsBasePath } from './internal-links' import { AccordionItem } from './markdown-schema/Accordion' import { Admonition } from './markdown-schema/Admonition' import { AgentSetup } from './markdown-schema/AgentSetup' import { AgentWatchSchedule } from './markdown-schema/AgentWatchSchedule' import { AiPrompt } from './markdown-schema/AiPrompt' import { AiSkillsIndex } from './markdown-schema/AiSkillsIndex' import { AuthProviders } from './markdown-schema/AuthProviders' import { ComputeDiskLimitsTable } from './markdown-schema/ComputeDiskLimitsTable' import { ContentListings } from './markdown-schema/ContentListings' import { CustomContent } from './markdown-schema/CustomContent' import { DatabaseAdvisorsIndex } from './markdown-schema/DatabaseAdvisorsIndex' import { ErrorCodes } from './markdown-schema/ErrorCodes' import { IconCheck, IconX } from './markdown-schema/Icons' import { Image } from './markdown-schema/Image' import { Link } from './markdown-schema/Link' import { McpCiConfigBlock } from './markdown-schema/McpCiConfigBlock' import { MetricsStackCards } from './markdown-schema/MetricsStackCards' import { NavData } from './markdown-schema/NavData' import { Panel } from './markdown-schema/Panel' import { Price } from './markdown-schema/Price' import { PromptPanel } from './markdown-schema/PromptPanel' import { RealtimeLimitsEstimator } from './markdown-schema/RealtimeLimitsEstimator' import { RegionsList, SmartRegionsList } from './markdown-schema/RegionsList' import { SharedData } from './markdown-schema/SharedData' import { StepHike } from './markdown-schema/StepHike' import { TabPanel } from './markdown-schema/TabPanel' import { TerraformProviderSchema } from './markdown-schema/TerraformProviderSchema' import { WrapperDashboardIntegration } from './markdown-schema/WrapperDashboardIntegration' import { collectMarkdownSources, type FrontmatterFormat, type MarkdownSource, } from './markdown-sources' const PARTIALS_DIR = path.join(process.cwd(), 'content', '_partials') // The set of guide slugs that have a generated markdown (.md) variant. middleware.ts // imports this to decide whether a bot request for /guides/ can be served // markdown. It lives under the gitignored public/markdown/ output dir and is // regenerated by build:guides-markdown, which turbo runs ahead of build/typecheck/lint. const MANIFEST_PATH = path.join(process.cwd(), 'public', 'markdown', 'manifest.json') const TROUBLESHOOTING_INDEX_PATH = path.join( process.cwd(), 'public', 'markdown', 'guides', 'troubleshooting.md' ) type JsxNode = MdxJsxFlowElement | MdxJsxTextElement type Props = Record /** * A handler converts a single MDX component into a markdown string. It receives * the component's props, the already-serialized markdown of its children, and * the raw AST node (escape hatch for handlers that need to inspect structure). * * Any component not in the schema is treated as `({ children }) => children`, * i.e. the wrapper is dropped and its children are kept as-is. */ type ComponentHandler = (ctx: { props: Props; children: string; node: JsxNode }) => string type ComponentSchema = Record const PARSE_OPTIONS = { extensions: [mdxjs(), gfm()], mdastExtensions: [mdxFromMarkdown(), gfmFromMarkdown()], } const SERIALIZE_OPTIONS = { extensions: [mdxToMarkdown(), gfmToMarkdown()], bullet: '-' as const, listItemIndent: 'one' as const, } const parseMdx = (source: string): Root => fromMarkdown(source, PARSE_OPTIONS) const serializeMdx = (tree: Parent): string => toMarkdown(tree as Root, SERIALIZE_OPTIONS) const defaultHandler: ComponentHandler = ({ children }) => children const isJsx = (n: Content): n is JsxNode => n.type === 'mdxJsxFlowElement' || n.type === 'mdxJsxTextElement' function propsFrom(node: JsxNode): Props { const props: Props = {} for (const attr of node.attributes) { if (attr.type !== 'mdxJsxAttribute') continue if (attr.value == null) props[attr.name] = true else if (typeof attr.value === 'string') props[attr.name] = attr.value else props[attr.name] = attr.value.value } return props } function resolvePartialPath(partialPath: string): string { if (!partialPath.endsWith('.md') && !partialPath.endsWith('.mdx')) { throw new Error('Invalid $Partial path: path must end with .mdx or .md') } const resolved = path.join(PARTIALS_DIR, partialPath) if (!resolved.startsWith(PARTIALS_DIR)) { throw new Error(`Invalid $Partial path: path must be inside ${PARTIALS_DIR}`) } return resolved } /** * Replaces every `<$Partial path="..." />` in the tree with the parsed AST of * the referenced file. Recurses so partials may include other partials. */ async function inlinePartials(parent: Parent): Promise { const next: Content[] = [] for (const child of parent.children as Content[]) { if (isJsx(child) && child.name === '$Partial') { const props = propsFrom(child) const partialPath = String(props.path ?? '') const variables = parsePartialVariables(props.variables) const resolvedPath = resolvePartialPath(partialPath) try { const raw = await fs.readFile(resolvedPath, 'utf8') const content = substitutePartialVars(matter(raw).content, variables) const subtree = parseMdx(content) await inlinePartials(subtree) next.push(...(subtree.children as Content[])) } catch { // missing or broken partials are silently dropped } continue } if ('children' in child) await inlinePartials(child as Parent) next.push(child) } parent.children = next as Parent['children'] } /** * Walks the tree bottom-up. For each JSX element, runs its schema handler (or * the default) and replaces the node with an `html` node holding the result. * The `html` type passes through `mdast-util-to-markdown` verbatim, so whatever * markdown the handler returns lands in the final output unchanged. */ function applySchema(parent: Parent, schema: ComponentSchema): void { for (const child of parent.children as Content[]) { if ('children' in child) applySchema(child as Parent, schema) } const next: Content[] = [] for (const child of parent.children as Content[]) { if ( child.type === 'mdxFlowExpression' || child.type === 'mdxTextExpression' || child.type === 'mdxjsEsm' ) { continue } if (isJsx(child)) { const handler = schema[child.name ?? ''] ?? defaultHandler const children = serializeMdx({ type: 'root', children: child.children as Root['children'], }).trim() const value = handler({ props: propsFrom(child), children, node: child }) next.push({ type: 'html', value } as Content) continue } next.push(child) } parent.children = next as Parent['children'] } /** * Per-component overrides. Each handler receives `{ props, children, node }` * and returns the markdown string that should replace the JSX element. Any * component not listed is unwrapped (children are kept, wrapper is dropped). */ const SCHEMA: ComponentSchema = { AccordionItem, Admonition, AgentSetup, AgentWatchSchedule, AiPrompt, AiSkillsIndex, IconCheck, IconX, Image, AuthProviders, ComputeDiskLimitsTable, CustomContent, DatabaseAdvisorsIndex, ErrorCodes, Link, McpCiConfigBlock, McpConfigPanel, Price, ...PromptPanel, GlassPanel: Panel, RealtimeLimitsEstimator, RegionsList, SmartRegionsList, ...StepHike, TabPanel, MetricsStackCards, ContentListings, NavData, SharedData, TerraformProviderSchema, WrapperDashboardIntegration, } function parseFrontmatter(raw: string, frontmatter: FrontmatterFormat) { if (frontmatter === 'toml') { return matter(raw, { language: 'toml', engines: { toml: parseToml } }) } return matter(raw) } async function transformBody(content: string, data: Record): Promise { const tree = parseMdx(content) await inlinePartials(tree) addBaseUrlPrefix(tree) applySchema(tree, SCHEMA) const body = serializeMdx(tree) const headerParts: string[] = [] if (data.title) headerParts.push(`# ${String(data.title)}`) if (data.subtitle) headerParts.push(String(data.subtitle)) if (data.description && String(data.description) !== String(data.subtitle)) headerParts.push(String(data.description)) const header = headerParts.join('\n\n') let output = header ? `${header}\n\n${body}` : body return output } async function parseSingleSource( sourceFile: string, frontmatter: FrontmatterFormat ): Promise { const raw = await fs.readFile(sourceFile, 'utf8') const { content, data } = parseFrontmatter(raw, frontmatter) return transformBody(content, data) } async function renderManifest(sources: MarkdownSource[], extraSlugs: string[] = []): Promise { const slugs = Array.from(new Set([...sources.map((s) => s.slug), ...extraSlugs])) const content = `${JSON.stringify(slugs, null, 2)}\n` await fs.mkdir(path.dirname(MANIFEST_PATH), { recursive: true }) await fs.writeFile(MANIFEST_PATH, content) } async function renderTroubleshootingIndex(troubleshooting: MarkdownSource[]): Promise { const entries = await Promise.all( troubleshooting.map(async ({ sourceFile, slug }) => { const raw = await fs.readFile(sourceFile, 'utf8') const { data } = parseFrontmatter(raw, 'toml') const url = `${getInternalLinkBaseUrl()}${withDocsBasePath(`/guides/${slug}`)}` return `- [${data.title}](${url})` }) ) const content = `# Troubleshooting guides\n\n${entries.join('\n')}\n` await fs.mkdir(path.dirname(TROUBLESHOOTING_INDEX_PATH), { recursive: true }) await fs.writeFile(TROUBLESHOOTING_INDEX_PATH, content) } async function generate() { const { guides, troubleshooting } = await collectMarkdownSources() const sources = [...guides, ...troubleshooting] await Promise.all( sources.map(async ({ sourceFile, outPath, frontmatter }) => { let output: string try { output = await parseSingleSource(sourceFile, frontmatter) } catch (err) { throw new Error( `Failed to process ${sourceFile}: ${err instanceof Error ? err.message : err}`, { cause: err } ) } await fs.mkdir(path.dirname(outPath), { recursive: true }) await fs.writeFile(outPath, output) }) ) await renderManifest(sources, ['troubleshooting']) await renderTroubleshootingIndex(troubleshooting) console.log( `Generated ${sources.length} markdown files under public/markdown/guides/, troubleshooting guides index at public/markdown/guides/troubleshooting.md and updated public/markdown/manifest.json` ) } generate().catch((error) => { console.error(error) process.exit(1) })