Files
supabase/apps/docs/internals/generate-guides-markdown.ts
T
36d2982af4 docs: add reusable monitoring agent setup components (#49506)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Stack

Draft stack extracted from `docs/monitoring`. Merge bottom-up.
Troubleshooting / debugging-guide rewrite is out of scope.

1. #49503 move inspect and advisors
2. #49501 split Studio logs from ClickHouse queries
3. #49500 treat reports as signal dashboards
4. #49502 add Observe the data hub
5. **#49506** add agent setup components ← **this PR**
6. #49504 add hire-an-agent templates
7. #49505 restructure observability nav and overview

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Docs app feature (MDX components + markdown export). Fifth layer in the
observability stack.

## What is the current behavior?

There is no shared way to render a monitoring agent prompt, schedule,
and Claude/Codex/Cursor setup instructions in both HTML and generated
markdown.

## What is the new behavior?

- `AgentSetup` and `AgentWatchSchedule` MDX components, registered for
HTML and markdown export
- Shared `monitoring-agents` data (cadence, prompt ids, harness steps)
- Opt-in `AiPrompt` markdown export (`includeInMarkdown`) so quickstarts
stay HTML-only
- Optional content-listing `subtitle` for schedule labels on cards

No agent guide pages yet — those land in #49504 so this PR stays a
reviewable code change.

## Additional context

Markdown schema handlers share the same data module as the React
components.
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-a3cb5ece-925b-4046-b58a-5d69e9a9d794?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-a3cb5ece-925b-4046-b58a-5d69e9a9d794&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saxon Fletcher <SaxonF@users.noreply.github.com>
2026-09-04 13:38:37 +10:00

313 lines
11 KiB
TypeScript

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/<slug> 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<string, unknown>
/**
* 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<string, ComponentHandler>
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<void> {
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<string, unknown>): Promise<string> {
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<string> {
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<void> {
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<void> {
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)
})