mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
<!-- 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> <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> </div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Saxon Fletcher <SaxonF@users.noreply.github.com>
313 lines
11 KiB
TypeScript
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)
|
|
})
|