diff --git a/package.json b/package.json
index 4397f7a2577..088b6c86682 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,8 @@
"quality-check": "npm run typecheck && npm run lint && npm run audit:exports",
"quality-check:full": "npm run typecheck && npm run lint && npm run audit:dead-code",
"generate:llms": "tsx --require tsconfig-paths/register src/scripts/generate-llms.ts",
- "check:llms": "tsx --require tsconfig-paths/register src/scripts/validate-llms.ts"
+ "check:llms": "tsx --require tsconfig-paths/register src/scripts/validate-llms.ts",
+ "check:markdown-fidelity": "tsx --require tsconfig-paths/register src/scripts/check-markdown-fidelity.ts"
},
"dependencies": {
"@11ty/eleventy-fetch": "^4.0.1",
diff --git a/src/lib/markdown/__tests__/buildMarkdownArtifact.test.ts b/src/lib/markdown/__tests__/buildMarkdownArtifact.test.ts
new file mode 100644
index 00000000000..8312f720ce8
--- /dev/null
+++ b/src/lib/markdown/__tests__/buildMarkdownArtifact.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from "@jest/globals"
+import {
+ buildMarkdownArtifact,
+ normalizeMarkdownPath,
+ transformPageBodyToMarkdown,
+} from "@lib/markdown/buildMarkdownArtifact.js"
+
+describe("buildMarkdownArtifact", () => {
+ it.each([
+ ["cre/getting-started/cli-installation", "normal"],
+ ["cre-templates", "special"],
+ ["cre/reference/sdk/evm-client", "selector"],
+ ["data-streams/getting-started", "redirect"],
+ ])("classifies %s as %s", async (requestPath, routeKind) => {
+ const artifact = await buildMarkdownArtifact(requestPath)
+
+ expect(artifact).not.toBeNull()
+ expect(artifact?.requestPath).toBe(normalizeMarkdownPath(requestPath))
+ expect(artifact?.routeKind).toBe(routeKind)
+ })
+
+ it("rejects path escapes", async () => {
+ expect(normalizeMarkdownPath("../outside")).toBeNull()
+ await expect(buildMarkdownArtifact("../outside")).resolves.toBeNull()
+ })
+
+ it("trims long leading and trailing slash runs", () => {
+ const slashes = "/".repeat(100_000)
+
+ expect(normalizeMarkdownPath(`${slashes}cre/getting-started${slashes}`)).toBe("cre/getting-started")
+ })
+
+ it("accepts an existing extensionless production request path", async () => {
+ await expect(buildMarkdownArtifact("cre/getting-started/cli-installation")).resolves.toMatchObject({
+ requestPath: "cre/getting-started/cli-installation",
+ routeKind: "normal",
+ })
+ })
+
+ it.each([".md", ".md.md", ".mdx"])("rejects a leftover %s extension", async (extension) => {
+ await expect(buildMarkdownArtifact(`cre/getting-started/cli-installation${extension}`)).resolves.toBeNull()
+ })
+
+ it.each([
+ "cre/getting-started/cli-installation",
+ "cre/getting-started/cli-installation/macos-linux",
+ "cre/getting-started/cli-installation/windows",
+ ])("projects the ordered operating system selector for %s", async (requestPath) => {
+ const artifact = await buildMarkdownArtifact(requestPath)
+ const markdown = artifact?.markdown ?? ""
+ const macosLinux = "[macOS / Linux](/cre/getting-started/cli-installation/macos-linux)"
+ const windows = "[Windows](/cre/getting-started/cli-installation/windows)"
+
+ expect(markdown).toContain("## Select your operating system")
+ expect(markdown).toContain(macosLinux)
+ expect(markdown).toContain(windows)
+ expect(markdown.indexOf(macosLinux)).toBeLessThan(markdown.indexOf(windows))
+ })
+})
+
+describe("transformPageBodyToMarkdown", () => {
+ it("reports the normal transform branch", async () => {
+ const result = await transformPageBodyToMarkdown("# Kept", "/virtual/normal.mdx")
+
+ expect(result.transformMode).toBe("normal")
+ expect(result.markdown).toContain("# Kept")
+ })
+
+ it("reports the sanitized retry branch", async () => {
+ const body = `export async function load() {
+ return @
+}
+
+# Kept`
+ const result = await transformPageBodyToMarkdown(body, "/virtual/sanitized.mdx")
+
+ expect(result.transformMode).toBe("sanitized")
+ expect(result.markdown).toContain("# Kept")
+ expect(result.markdown).not.toContain("return @")
+ })
+
+ it("reports the fallback branch", async () => {
+ const body = `# Kept
+
+{`
+ const result = await transformPageBodyToMarkdown(body, "/virtual/fallback.mdx")
+
+ expect(result).toEqual({
+ transformMode: "fallback",
+ markdown: body,
+ })
+ })
+
+ it("strips component tags in the fallback branch", async () => {
+ const result = await transformPageBodyToMarkdown(
+ `Visible
+{`,
+ "/virtual/fallback-components.mdx"
+ )
+
+ expect(result).toEqual({
+ transformMode: "fallback",
+ markdown: `Visible
+{`,
+ })
+ })
+
+ it("preserves a long unterminated repeated component prefix in the fallback branch", async () => {
+ const body = `${" {
+ const result = await transformPageBodyToMarkdown("ignored", "/virtual/data-feeds/deprecating-feeds.mdx")
+
+ expect(result.transformMode).toBe("replacement")
+ expect(result.markdown).toContain("## Deprecated Feeds")
+ })
+})
diff --git a/src/lib/markdown/__tests__/sourceScanners.test.ts b/src/lib/markdown/__tests__/sourceScanners.test.ts
new file mode 100644
index 00000000000..69783689005
--- /dev/null
+++ b/src/lib/markdown/__tests__/sourceScanners.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from "@jest/globals"
+import {
+ readStaticDefaultImports,
+ readStaticJsxSelectorConditions,
+ removeLeadingMdxFrontmatter,
+ stripHighlighterComments,
+} from "@lib/markdown/sourceScanners.js"
+
+describe("readStaticDefaultImports", () => {
+ it("reads single-line and multiline static default imports", () => {
+ const imports = readStaticDefaultImports(`---
+import Alpha from "./alpha.mdx"
+import $Code
+ from
+ './code.ts?raw'
+import { ignored } from "./named.js"
+import "./side-effect.js"
+---`)
+
+ expect(Object.fromEntries(imports)).toEqual({
+ Alpha: "./alpha.mdx",
+ $Code: "./code.ts?raw",
+ })
+ })
+
+ it("scans repeated unterminated import prefixes deterministically", () => {
+ const source = `${'import Broken from "unterminated\n'.repeat(10_000)}import Kept from "./kept.mdx"`
+
+ expect(Object.fromEntries(readStaticDefaultImports(source))).toEqual({ Kept: "./kept.mdx" })
+ expect(Object.fromEntries(readStaticDefaultImports(source))).toEqual({ Kept: "./kept.mdx" })
+ })
+})
+
+describe("readStaticJsxSelectorConditions", () => {
+ it("maps static selector values to JSX components", () => {
+ const conditions = readStaticJsxSelectorConditions(
+ `{callout === "alpha" && }
+{callout
+ ===
+ 'beta'
+ &&
+ }`,
+ "callout"
+ )
+
+ expect(Object.fromEntries(conditions)).toEqual({ alpha: "Alpha", beta: "Beta" })
+ })
+
+ it("scans repeated unterminated selector prefixes deterministically", () => {
+ const source = `${'{callout === "unterminated\n'.repeat(10_000)}{callout === "kept" && }`
+
+ expect(Object.fromEntries(readStaticJsxSelectorConditions(source, "callout"))).toEqual({ kept: "Kept" })
+ expect(Object.fromEntries(readStaticJsxSelectorConditions(source, "callout"))).toEqual({ kept: "Kept" })
+ })
+})
+
+describe("removeLeadingMdxFrontmatter", () => {
+ it.each([
+ ["LF", "---\ntitle: Example\n---\n\n# Body\n", "\n# Body\n"],
+ ["CRLF", "---\r\ntitle: Example\r\n---\r\n# Body\r\n", "# Body\r\n"],
+ ["trailing fence whitespace", "--- \ntitle: Example\n--- \n# Body", "# Body"],
+ ])("removes leading %s frontmatter without changing body newlines", (_name, source, expected) => {
+ expect(removeLeadingMdxFrontmatter(source)).toBe(expected)
+ })
+
+ it("preserves missing and unterminated frontmatter", () => {
+ expect(removeLeadingMdxFrontmatter("# Body\n---\n")).toBe("# Body\n---\n")
+ expect(removeLeadingMdxFrontmatter("---\ntitle: Example")).toBe("---\ntitle: Example")
+ })
+})
+
+describe("stripHighlighterComments", () => {
+ it("removes supported markers while preserving other text and whitespace-only lines", () => {
+ const code = `const value = 1 // highlight-line
+
+\t// highlight-start
+next // regular comment
+end // highlight-end `
+
+ expect(stripHighlighterComments(code)).toBe(`const value = 1
+
+
+next // regular comment
+end `)
+ })
+})
diff --git a/src/lib/markdown/__tests__/transformMarkdown.test.ts b/src/lib/markdown/__tests__/transformMarkdown.test.ts
index b564bc74200..978900b82ae 100644
--- a/src/lib/markdown/__tests__/transformMarkdown.test.ts
+++ b/src/lib/markdown/__tests__/transformMarkdown.test.ts
@@ -52,6 +52,218 @@ contract Test {
expect(result).toContain("Col1")
expect(result).toContain("Col2")
})
+
+ it("projects PageTabs in source order with grouped labels and first URLs", async () => {
+ const result = await transformMarkdown(
+ ``,
+ "/fake/page.mdx"
+ )
+
+ expect(result).toContain("## Select your operating system")
+ expect(result).toContain("[macOS / Linux](/install/macos)")
+ expect(result).not.toContain("/install/linux")
+ expect(result.indexOf("[macOS / Linux]")).toBeLessThan(result.indexOf("[Windows]"))
+
+ const withoutHeader = await transformMarkdown(
+ ``,
+ "/fake/page.mdx"
+ )
+ expect(withoutHeader).not.toContain("## Guide Versions")
+ expect(withoutHeader).toContain("[Only](/only)")
+ })
+
+ it("pairs Tabs and TabsContent labels with matching panels", async () => {
+ for (const component of ["Tabs", "TabsContent"]) {
+ const result = await transformMarkdown(
+ `<${component}>
+ First
+ Second
+ Second panel
+ First panel
+${component}>`,
+ "/fake/page.mdx"
+ )
+
+ expect(result.indexOf("### First")).toBeLessThan(result.indexOf("First panel"))
+ expect(result.indexOf("First panel")).toBeLessThan(result.indexOf("### Second"))
+ expect(result.indexOf("### Second")).toBeLessThan(result.indexOf("Second panel"))
+ }
+ })
+
+ it("projects PackageManagerTabs as npm then yarn even when yarn is first", async () => {
+ const result = await transformMarkdown(
+ `
+ yarn add example
+ npm install example
+`,
+ "/fake/page.mdx"
+ )
+
+ expect(result.indexOf("### npm")).toBeLessThan(result.indexOf("npm install example"))
+ expect(result.indexOf("npm install example")).toBeLessThan(result.indexOf("### yarn"))
+ expect(result.indexOf("### yarn")).toBeLessThan(result.indexOf("yarn add example"))
+ })
+
+ it("unwraps Fragment content", async () => {
+ const result = await transformMarkdown(`Visible **content**`, "/fake/page.mdx")
+ expect(result).toContain("Visible **content**")
+ expect(result).not.toContain("Fragment")
+ })
+
+ it("projects Accordion number, title, and body", async () => {
+ const result = await transformMarkdown(
+ `
+Body instructions.
+`,
+ "/fake/page.mdx"
+ )
+ expect(result).toContain("### 2. Deploy the contract")
+ expect(result).toContain("Body instructions.")
+ })
+
+ it("uses an Accordion title slot instead of the title prop", async () => {
+ const withTitleSlot = await transformMarkdown(
+ `
+ Review the deployment
+Body remains visible.
+`,
+ "/fake/page.mdx"
+ )
+ expect(withTitleSlot).toContain("### 3. Review the deployment")
+ expect(withTitleSlot).not.toContain("Ignored prop title")
+ expect(withTitleSlot).toContain("Body remains visible.")
+ })
+
+ it("projects Address with exact URLs and static truncation", async () => {
+ const result = await transformMarkdown(
+ `Exact:
+Truncated: `,
+ "/fake/page.mdx"
+ )
+ expect(result).toContain("[0x1234567890abcdef](https://example.test/address/0x1234567890abcdef?view=code)")
+ expect(result).toContain("[0x1234...cdef](https://example.test/exact)")
+ })
+
+ it("projects a block ClickToZoom as an exact Markdown image", async () => {
+ const result = await transformMarkdown(
+ ``,
+ "/fake/page.mdx"
+ )
+
+ expect(result).toBe("\n")
+ })
+
+ it("projects an inline ClickToZoom with default alt text", async () => {
+ const result = await transformMarkdown(`Before after.`, "/fake/page.mdx")
+
+ expect(result).toBe("Before  after.\n")
+ })
+
+ it("projects ClickToZoom through the AST with a long repeated attribute value", async () => {
+ const repeated = " =".repeat(50_000)
+ const result = await transformMarkdown(
+ ``,
+ "/fake/page.mdx"
+ )
+
+ expect(result).toBe("\n")
+ })
+
+ it("projects Aside as a Markdown blockquote", async () => {
+ const result = await transformMarkdown(
+ ``,
+ "/fake/page.mdx"
+ )
+ expect(result).toContain("> **WARNING: Important**")
+ expect(result).toContain("> Read the warning.")
+ })
+
+ it("projects an Aside with a long repeated attribute value", async () => {
+ const repeated = "a".repeat(100_000)
+ const result = await transformMarkdown(
+ ``,
+ "/fake/page.mdx"
+ )
+ expect(result).toContain(`> **NOTE: ${repeated}**`)
+ expect(result).toContain("> Body.")
+ })
+
+ it("projects Callout like Aside", async () => {
+ const result = await transformMarkdown(
+ `
+Read the warning.
+`,
+ "/fake/page.mdx"
+ )
+ expect(result).toContain("> **CAUTION: Check this**")
+ expect(result).toContain("> Read the warning.")
+ })
+
+ it("projects SchemaFieldsTable from report schema definitions", async () => {
+ const result = await transformMarkdown(``, "/fake/page.mdx")
+ expect(result).toContain("| Field")
+ expect(result).toContain("`feedId`")
+ expect(result).toContain("`price`")
+ expect(result).toContain("Time-weighted average price")
+ })
+
+ it("projects every static CodeHighlightBlockMulti language when no target is set", async () => {
+ const result = await transformMarkdown(
+ ``,
+ "/fake/page.mdx"
+ )
+ expect(result).toContain("```ts")
+ expect(result).toContain("const answer = 42")
+ expect(result).toContain("```go")
+ expect(result).toContain("package main")
+ expect(result.indexOf("```ts")).toBeLessThan(result.indexOf("```go"))
+
+ const selected = await transformMarkdown(
+ ``,
+ "/fake/page.mdx",
+ { targetLanguage: "go" }
+ )
+ expect(selected).not.toContain("```ts")
+ expect(selected).not.toContain("ts only")
+ expect(selected).toContain("```go")
+ expect(selected).toContain("go only")
+ })
+
+ it("removes residual MDX, HTML, ESM, and nonliteral projections", async () => {
+ const result = await transformMarkdown(
+ `import Unknown from "./Unknown"
+
+Before hidden JSX after.
+hidden HTML
+{dynamicValue}
+`,
+ "/fake/page.mdx"
+ )
+ expect(result).toContain("Before")
+ expect(result).toContain("after.")
+ expect(result).not.toMatch(/<[/A-Za-z]/)
+ expect(result).not.toContain("import Unknown")
+ expect(result).not.toContain("{dynamicValue}")
+ expect(result).not.toContain("dynamicPages")
+ })
})
describe("extractFrontmatter", () => {
diff --git a/src/lib/markdown/buildMarkdownArtifact.ts b/src/lib/markdown/buildMarkdownArtifact.ts
new file mode 100644
index 00000000000..4c466d18dc8
--- /dev/null
+++ b/src/lib/markdown/buildMarkdownArtifact.ts
@@ -0,0 +1,378 @@
+import fs from "node:fs/promises"
+import path from "node:path"
+import { transformPageToMarkdown } from "./transformMarkdown.js"
+import type { MarkdownArtifact } from "./types.js"
+import { extractFrontmatter, getIsoStringOrUndefined, toCanonicalUrl, toContentRelative } from "./utils.js"
+
+const SITE_BASE = "https://docs.chain.link"
+const CONTENT_ROOT = path.resolve("src/content")
+const LLMS_DIRECTIVE = "> For the complete documentation index, see [llms.txt](/llms.txt)."
+
+const MARKDOWN_REDIRECTS: Record = {
+ "ccip/tutorials/cross-chain-tokens": "ccip/tutorials/evm/cross-chain-tokens",
+
+ // Data Streams
+ "data-streams/getting-started": "data-streams/tutorials/streams-trade/getting-started",
+ "data-streams/getting-started-hardhat": "data-streams/tutorials/streams-trade/getting-started-hardhat",
+ "data-streams/reference/streams-direct/streams-direct-onchain-verification":
+ "data-streams/reference/onchain-verification",
+
+ // Newly surfaced redirects
+ "chainlink-functions/resources/concepts": "chainlink-functions/resources",
+ "cre/getting-started/conclusion": "cre/getting-started",
+ "data-streams/reference/streams-direct/streams-direct-interface-ws": "data-streams/reference/interface-ws",
+}
+
+type TransformOutcome = Pick
+
+type SpecialResolution = {
+ resolvedPath: string
+ sourceCanonicalPath: string
+ sourcePath: string
+}
+
+type CreResolution =
+ | { kind: "none" }
+ | { kind: "resolved"; path: string; sourcePath: string }
+ | { kind: "selector"; goPath: string; tsPath: string }
+
+export function normalizeMarkdownPath(pathParam: string | undefined): string | null {
+ if (!pathParam) return null
+
+ let start = 0
+ let end = pathParam.length
+ while (start < end && pathParam[start] === "/") start++
+ while (end > start && pathParam[end - 1] === "/") end--
+ const cleanPath = pathParam.slice(start, end)
+
+ if (!cleanPath || /\.(?:md|mdx)$/i.test(cleanPath)) return null
+
+ const segments = cleanPath.split("/")
+ if (segments.some((segment) => segment === ".." || segment === "." || segment === "")) {
+ return null
+ }
+
+ return cleanPath
+}
+
+export async function buildMarkdownArtifact(
+ requestPath: string,
+ options: { lang?: string } = {}
+): Promise {
+ const cleanPath = normalizeMarkdownPath(requestPath)
+ if (!cleanPath) return null
+
+ const specialResolution = await resolveSpecialCanonicalMarkdownPath(cleanPath)
+ if (specialResolution) {
+ return buildMarkdownArtifactFromPath(
+ cleanPath,
+ specialResolution.resolvedPath,
+ "special",
+ options,
+ specialResolution.sourcePath,
+ specialResolution.sourceCanonicalPath
+ )
+ }
+
+ const creResolution = await resolveCreCanonicalMarkdownPath(cleanPath)
+ if (creResolution.kind === "selector") {
+ return {
+ requestPath: cleanPath,
+ routeKind: "selector",
+ transformMode: "normal",
+ markdown: buildCreSelectorMarkdown(cleanPath, creResolution),
+ }
+ }
+
+ const resolvedPath = creResolution.kind === "resolved" ? creResolution.path : cleanPath
+ const redirectTarget = MARKDOWN_REDIRECTS[resolvedPath]
+ if (redirectTarget) {
+ return {
+ requestPath: cleanPath,
+ routeKind: "redirect",
+ transformMode: "normal",
+ markdown: buildMarkdownMovedBody(resolvedPath, redirectTarget),
+ }
+ }
+
+ return buildMarkdownArtifactFromPath(
+ cleanPath,
+ resolvedPath,
+ "normal",
+ options,
+ creResolution.kind === "resolved" ? creResolution.sourcePath : undefined
+ )
+}
+
+export async function transformPageBodyToMarkdown(
+ body: string,
+ mdxAbsPath: string,
+ options: { siteBase?: string; targetLanguage?: string } = {}
+): Promise {
+ if (mdxAbsPath.includes("data-feeds/deprecating-feeds")) {
+ return {
+ transformMode: "replacement",
+ markdown: `
+## Deprecated Feeds
+
+This page contains dynamically generated or component-heavy content.
+
+For the full and most up-to-date information, see:
+https://docs.chain.link/data-feeds/deprecating-feeds
+`.trim(),
+ }
+ }
+
+ const transformOptions = {
+ siteBase: options.siteBase ?? SITE_BASE,
+ targetLanguage: options.targetLanguage,
+ }
+
+ try {
+ return {
+ transformMode: "normal",
+ markdown: await transformPageToMarkdown(body, mdxAbsPath, transformOptions),
+ }
+ } catch {
+ const sanitizedBody = stripRuntimeMdxSyntax(body)
+
+ try {
+ return {
+ transformMode: "sanitized",
+ markdown: await transformPageToMarkdown(sanitizedBody, mdxAbsPath, transformOptions),
+ }
+ } catch {
+ return {
+ transformMode: "fallback",
+ markdown: buildFallbackMarkdownBody(sanitizedBody),
+ }
+ }
+ }
+}
+
+async function resolveSpecialCanonicalMarkdownPath(cleanPath: string): Promise {
+ const specialPathMap: Record = {
+ "cre-templates": "cre/templates",
+ }
+
+ const resolvedPath = specialPathMap[cleanPath]
+ if (!resolvedPath) return null
+
+ const sourcePath = await findContentFile(resolvedPath)
+ if (!sourcePath) return null
+
+ return {
+ resolvedPath,
+ sourceCanonicalPath: cleanPath,
+ sourcePath,
+ }
+}
+
+async function resolveCreCanonicalMarkdownPath(cleanPath: string): Promise {
+ if (!cleanPath.startsWith("cre/")) {
+ return { kind: "none" }
+ }
+
+ const direct = await findContentFile(cleanPath)
+ if (direct) {
+ return { kind: "resolved", path: cleanPath, sourcePath: direct }
+ }
+
+ const goPath = `${cleanPath}-go`
+ const tsPath = `${cleanPath}-ts`
+ const [goFile, tsFile] = await Promise.all([findContentFile(goPath), findContentFile(tsPath)])
+
+ if (goFile && tsFile) {
+ return { kind: "selector", goPath, tsPath }
+ }
+
+ if (goFile) {
+ return { kind: "resolved", path: goPath, sourcePath: goFile }
+ }
+
+ if (tsFile) {
+ return { kind: "resolved", path: tsPath, sourcePath: tsFile }
+ }
+
+ return { kind: "none" }
+}
+
+async function buildMarkdownArtifactFromPath(
+ requestPath: string,
+ resolvedPath: string,
+ routeKind: "normal" | "special",
+ options: { lang?: string },
+ knownSourcePath?: string,
+ sourceCanonicalPathOverride?: string
+): Promise {
+ const sourcePath = knownSourcePath ?? (await findContentFile(resolvedPath))
+ if (!sourcePath) return null
+
+ const raw = await fs.readFile(sourcePath, "utf-8")
+ const { body, fmTitle, fmLastModified } = extractFrontmatter(raw)
+ const transformed = await transformPageBodyToMarkdown(body, sourcePath, {
+ siteBase: SITE_BASE,
+ targetLanguage: options.lang,
+ })
+
+ const section = resolvedPath.split("/")[0]
+ const relFromContent = toContentRelative(sourcePath)
+ const derivedSourceUrl = toCanonicalUrl(section, relFromContent, SITE_BASE)
+ const sourceUrl = sourceCanonicalPathOverride ? `${SITE_BASE}/${sourceCanonicalPathOverride}` : derivedSourceUrl
+ const title = fmTitle || path.basename(sourcePath, path.extname(sourcePath))
+ const lastModified = getIsoStringOrUndefined(fmLastModified)
+ const headerLines = [
+ `# ${title}`,
+ `Source: ${sourceUrl}`,
+ ...(lastModified ? [`Last Updated: ${lastModified}`] : []),
+ "",
+ LLMS_DIRECTIVE,
+ "",
+ ]
+
+ return {
+ requestPath,
+ routeKind,
+ transformMode: transformed.transformMode,
+ sourcePath: relFromContent,
+ markdown: [...headerLines, transformed.markdown.trim()].join("\n"),
+ }
+}
+
+async function findContentFile(cleanPath: string): Promise {
+ const possiblePaths = [
+ path.resolve(CONTENT_ROOT, `${cleanPath}.mdx`),
+ path.resolve(CONTENT_ROOT, cleanPath, "index.mdx"),
+ path.resolve(CONTENT_ROOT, `${cleanPath}.md`),
+ path.resolve(CONTENT_ROOT, cleanPath, "index.md"),
+ ]
+
+ for (const candidate of possiblePaths) {
+ if (!candidate.startsWith(`${CONTENT_ROOT}${path.sep}`)) continue
+ try {
+ await fs.access(candidate)
+ return candidate
+ } catch {}
+ }
+
+ return null
+}
+
+function buildFallbackMarkdownBody(body: string): string {
+ return stripFallbackComponentTags(stripRuntimeMdxSyntax(body)).trim()
+}
+
+function stripFallbackComponentTags(body: string): string {
+ const chunks: string[] = []
+ let copiedThrough = 0
+ let searchFrom = 0
+
+ while (searchFrom < body.length) {
+ const tagStart = body.indexOf("<", searchFrom)
+ if (tagStart === -1) break
+
+ const nameStart = body.charCodeAt(tagStart + 1) === 47 ? tagStart + 2 : tagStart + 1
+ const firstNameChar = body.charCodeAt(nameStart)
+ if (firstNameChar < 65 || firstNameChar > 90) {
+ searchFrom = tagStart + 1
+ continue
+ }
+
+ const tagEnd = body.indexOf(">", nameStart + 1)
+ if (tagEnd === -1) {
+ chunks.push(body.slice(copiedThrough))
+ return chunks.join("")
+ }
+
+ chunks.push(body.slice(copiedThrough, tagStart))
+ copiedThrough = tagEnd + 1
+ searchFrom = copiedThrough
+ }
+
+ chunks.push(body.slice(copiedThrough))
+ return chunks.join("")
+}
+
+function stripRuntimeMdxSyntax(body: string): string {
+ const lines = body.split("\n")
+ const output: string[] = []
+ let skippingExportBlock = false
+ let skippingImportBlock = false
+ let braceDepth = 0
+
+ for (const line of lines) {
+ const trimmed = line.trim()
+
+ if (skippingImportBlock) {
+ if (trimmed.includes(" from ") || trimmed.endsWith('"') || trimmed.endsWith("'")) {
+ skippingImportBlock = false
+ }
+ continue
+ }
+
+ if (skippingExportBlock) {
+ braceDepth += countChar(line, "{")
+ braceDepth -= countChar(line, "}")
+
+ if (braceDepth <= 0) {
+ skippingExportBlock = false
+ braceDepth = 0
+ }
+ continue
+ }
+
+ if (/^import\s+/.test(trimmed)) {
+ if (!trimmed.includes(" from ")) skippingImportBlock = true
+ continue
+ }
+
+ if (/^export\s+(async\s+)?function\s+/.test(trimmed)) {
+ skippingExportBlock = true
+ braceDepth = countChar(line, "{") - countChar(line, "}")
+ continue
+ }
+
+ if (/^export\s+(const|let|var)\s+/.test(trimmed)) {
+ continue
+ }
+
+ output.push(line)
+ }
+
+ return output.join("\n")
+}
+
+function countChar(value: string, char: string): number {
+ return value.split(char).length - 1
+}
+
+function buildMarkdownMovedBody(sourcePath: string, targetPath: string): string {
+ const sourceUrl = `${SITE_BASE}/${sourcePath}`
+ const targetUrl = `/${targetPath}.md`
+
+ return [
+ "# Redirect",
+ `Source: ${sourceUrl}`,
+ "",
+ LLMS_DIRECTIVE,
+ "",
+ "This page has moved.",
+ "",
+ `Use the current documentation: [${targetPath}](${targetUrl}).`,
+ "",
+ ].join("\n")
+}
+
+function buildCreSelectorMarkdown(canonicalPath: string, resolution: { goPath: string; tsPath: string }): string {
+ const canonicalUrl = `${SITE_BASE}/${canonicalPath}`
+ return [
+ `# ${canonicalPath}`,
+ `Source: ${canonicalUrl}`,
+ "",
+ LLMS_DIRECTIVE,
+ "",
+ `- Go: /${resolution.goPath}.md`,
+ `- TypeScript: /${resolution.tsPath}.md`,
+ "",
+ ].join("\n")
+}
diff --git a/src/lib/markdown/componentHandlers.ts b/src/lib/markdown/componentHandlers.ts
index 9db02c21a32..84e836f0143 100644
--- a/src/lib/markdown/componentHandlers.ts
+++ b/src/lib/markdown/componentHandlers.ts
@@ -6,11 +6,163 @@ import fs from "fs"
import path from "path"
import type { Parent, Literal, Node } from "unist"
import type { MdxJsxNode, ComponentContext } from "./types.js"
+import {
+ readStaticDefaultImports,
+ readStaticJsxSelectorConditions,
+ removeLeadingMdxFrontmatter,
+ stripHighlighterComments,
+} from "./sourceScanners.js"
import {
calculateNetworkFeesForTokenMechanismDirect,
calculateMessagingNetworkFeesDirect,
- TokenMechanism,
-} from "../../config/data/ccip/index.js"
+} from "../../config/data/ccip/utils.js"
+import { TokenMechanism } from "../../config/data/ccip/types.js"
+import { REPORT_SCHEMA_DEFINITIONS } from "../../features/feeds/components/reportSchemaData.js"
+
+type StaticValue = null | boolean | number | string | StaticValue[] | { [key: string]: StaticValue }
+type EstreeNode = {
+ type?: string
+ value?: unknown
+ name?: string
+ operator?: string
+ argument?: EstreeNode
+ elements?: (EstreeNode | null)[]
+ properties?: EstreeNode[]
+ key?: EstreeNode
+ computed?: boolean
+ kind?: string
+ method?: boolean
+ shorthand?: boolean
+ expressions?: EstreeNode[]
+ quasis?: { value?: { cooked?: string | null; raw?: string } }[]
+}
+
+const NON_STATIC = Symbol("non-static")
+
+function staticEstreeValue(node: EstreeNode | undefined): StaticValue | typeof NON_STATIC {
+ if (!node) return NON_STATIC
+ if (node.type === "Literal") {
+ return node.value === null || ["boolean", "number", "string"].includes(typeof node.value)
+ ? (node.value as StaticValue)
+ : NON_STATIC
+ }
+ if (node.type === "TemplateLiteral" && node.expressions?.length === 0 && node.quasis?.length === 1) {
+ return node.quasis[0].value?.cooked ?? node.quasis[0].value?.raw ?? ""
+ }
+ if (node.type === "UnaryExpression" && (node.operator === "+" || node.operator === "-")) {
+ const value = staticEstreeValue(node.argument)
+ return typeof value === "number" ? (node.operator === "-" ? -value : value) : NON_STATIC
+ }
+ if (node.type === "ArrayExpression") {
+ const values: StaticValue[] = []
+ for (const element of node.elements || []) {
+ if (!element) return NON_STATIC
+ const value = staticEstreeValue(element)
+ if (value === NON_STATIC) return NON_STATIC
+ values.push(value)
+ }
+ return values
+ }
+ if (node.type === "ObjectExpression") {
+ const value: { [key: string]: StaticValue } = {}
+ for (const property of node.properties || []) {
+ if (
+ property.type !== "Property" ||
+ property.computed ||
+ property.kind !== "init" ||
+ property.method ||
+ property.shorthand
+ ) {
+ return NON_STATIC
+ }
+ const key =
+ property.key?.type === "Identifier"
+ ? property.key.name
+ : property.key?.type === "Literal" &&
+ (typeof property.key.value === "string" || typeof property.key.value === "number")
+ ? String(property.key.value)
+ : undefined
+ const propertyValue = staticEstreeValue(property.value as EstreeNode)
+ if (key === undefined || propertyValue === NON_STATIC) return NON_STATIC
+ value[key] = propertyValue
+ }
+ return value
+ }
+ return NON_STATIC
+}
+
+function staticAttribute(node: MdxJsxNode, name: string): StaticValue | typeof NON_STATIC | undefined {
+ const attribute = node.attributes?.find((candidate) => candidate.name === name)
+ if (!attribute) return undefined
+ const rawValue = attribute.value as unknown
+ if (rawValue === null || rawValue === undefined) return true
+ if (typeof rawValue === "string") return rawValue
+ if (typeof rawValue !== "object") return NON_STATIC
+ const expression = (
+ rawValue as {
+ data?: { estree?: { body?: { expression?: EstreeNode }[] } }
+ }
+ ).data?.estree?.body?.[0]?.expression
+ return staticEstreeValue(expression)
+}
+
+function dropNode(parent: Parent, index: number): number {
+ parent.children.splice(index, 1)
+ return index
+}
+
+function textNode(value: string): Literal {
+ return { type: "text", value } as Literal
+}
+
+function headingNode(depth: number, value: string): Parent {
+ return { type: "heading", depth, children: [textNode(value)] } as Parent
+}
+
+function paragraphNode(children: Node[]): Parent {
+ return { type: "paragraph", children } as Parent
+}
+
+function linkNode(label: string, url: string): Parent {
+ return { type: "link", url, children: [textNode(label)] } as Parent
+}
+
+function staticNodeText(node: Node): string | typeof NON_STATIC {
+ if (node.type === "text" || node.type === "inlineCode") {
+ return typeof (node as Literal).value === "string" ? String((node as Literal).value) : NON_STATIC
+ }
+ if (node.type === "break") return " "
+ if (node.type === "paragraph" || node.type === "emphasis" || node.type === "strong" || node.type === "delete") {
+ const parts: string[] = []
+ for (const child of (node as Parent).children || []) {
+ const part = staticNodeText(child)
+ if (part === NON_STATIC) return NON_STATIC
+ parts.push(part)
+ }
+ return parts.join("")
+ }
+ return NON_STATIC
+}
+
+function staticChildrenText(node: Parent): string | typeof NON_STATIC {
+ const parts: string[] = []
+ for (const child of node.children || []) {
+ const part = staticNodeText(child)
+ if (part === NON_STATIC) return NON_STATIC
+ parts.push(part)
+ }
+ return parts.join("").trim()
+}
+
+function resolveExistingWithin(root: string, candidate: string): string | undefined {
+ try {
+ const realRoot = fs.realpathSync(root)
+ const realCandidate = fs.realpathSync(path.resolve(root, candidate))
+ if (realCandidate === realRoot || realCandidate.startsWith(realRoot + path.sep)) return realCandidate
+ } catch {
+ // Missing files are not projectable.
+ }
+}
/**
* Load CcipCommon callout mapping dynamically from CcipCommon.astro
@@ -22,24 +174,16 @@ export function loadCcipCommonMapping(): Record {
const astroContent = fs.readFileSync(astroFilePath, "utf-8")
// First, build a map of Component names to file paths from imports
- const importRegex = /import\s+(\w+)\s+from\s+["'](.+?)["']/g
- const componentToFile: Record = {}
-
- for (const match of astroContent.matchAll(importRegex)) {
- const [, componentName, filePath] = match
- const cleanPath = filePath.replace(/^\.\//, "")
- componentToFile[componentName] = cleanPath
- }
+ const componentToFile = readStaticDefaultImports(astroContent)
// Then, parse the conditional statements to map callout names to component names
- const conditionalRegex = /callout\s+===\s+["'](\w+)["']\s+&&\s+<(\w+)/g
+ const conditions = readStaticJsxSelectorConditions(astroContent, "callout")
const mapping: Record = {}
- for (const match of astroContent.matchAll(conditionalRegex)) {
- const [, calloutName, componentName] = match
- const filePath = componentToFile[componentName]
+ for (const [calloutName, componentName] of conditions) {
+ const filePath = componentToFile.get(componentName)
if (filePath) {
- mapping[calloutName] = filePath
+ mapping[calloutName] = filePath.startsWith("./") ? filePath.slice(2) : filePath
}
}
@@ -74,15 +218,13 @@ export function handleCcipCommon(
const fileName = calloutFileMap[calloutValue]
if (fileName) {
- const calloutPath = path.resolve("src/features/ccip", fileName)
+ const calloutPath = resolveExistingWithin(path.resolve("src/features/ccip"), fileName)
- if (fs.existsSync(calloutPath)) {
+ if (calloutPath) {
let calloutContent = fs.readFileSync(calloutPath, "utf-8")
// Strip frontmatter if present
- if (calloutContent.trim().startsWith("---")) {
- calloutContent = calloutContent.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, "")
- }
+ calloutContent = removeLeadingMdxFrontmatter(calloutContent)
// Strip import statements
calloutContent = calloutContent.replace(/^import\s+.+$/gm, "").trim()
@@ -91,13 +233,15 @@ export function handleCcipCommon(
const calloutTree = context.processor.parse(calloutContent)
if (calloutTree && calloutTree.children) {
parent.children.splice(index, 1, ...calloutTree.children)
- return index + calloutTree.children.length
+ return index
}
}
}
}
+ return dropNode(parent, index)
} catch (e) {
console.warn(`Failed to process CcipCommon in ${context.mdxAbsPath}:`, e)
+ return dropNode(parent, index)
}
}
@@ -121,19 +265,17 @@ export function handleCodeHighlightBlock(
?.data?.estree?.body?.[0]?.expression?.name
if (codeVarName) {
- const importRegex = new RegExp(`import\\s+${codeVarName}\\s+from\\s+['"](.+?)['"]`)
- const match = context.markdown.match(importRegex)
-
- if (match) {
- const importPath = match[1].split("?")[0] // Strip "?raw" and other query params
- const codeAbsPath = path.resolve(path.dirname(context.mdxAbsPath), importPath)
- let codeContent = fs.readFileSync(codeAbsPath, "utf-8")
-
- // Strip highlighter comments
- codeContent = codeContent
- .split("\n")
- .map((line) => line.replace(/\s*\/\/\s*highlight-(line|start|end)/, ""))
- .join("\n")
+ const importPath = readStaticDefaultImports(context.markdown).get(codeVarName)?.split("?")[0]
+
+ if (importPath) {
+ const codeAbsPath = resolveExistingWithin(
+ process.cwd(),
+ path.resolve(path.dirname(context.mdxAbsPath), importPath)
+ )
+ if (!codeAbsPath) {
+ return dropNode(parent, index)
+ }
+ const codeContent = stripHighlighterComments(fs.readFileSync(codeAbsPath, "utf-8"))
const langAttr = node.attributes?.find((a) => a.name === "lang")
const titleAttr = node.attributes?.find((a) => a.name === "title")
@@ -155,8 +297,10 @@ export function handleCodeHighlightBlock(
return index + newNodes.length
}
}
+ return dropNode(parent, index)
} catch (e) {
console.warn(`Failed to process CodeHighlightBlock in ${context.mdxAbsPath}:`, e)
+ return dropNode(parent, index)
}
}
@@ -175,72 +319,75 @@ export function handleCodeHighlightBlockMulti(
context: ComponentContext
): number | void {
try {
- const languagesAttr = node.attributes?.find((a) => a.name === "languages")
-
- if (languagesAttr && context.targetLanguage) {
- // Extract the code variable name for the target language
- // The structure is: languages={{ go: { code: goVar }, ts: { code: tsVar } }}
- const attrValue = languagesAttr.value
- const estreeBody =
- typeof attrValue === "object" && attrValue && "data" in attrValue
- ? attrValue.data?.estree?.body?.[0]
- : undefined
- const languagesObj =
- estreeBody && typeof estreeBody === "object" && "expression" in estreeBody
- ? (estreeBody.expression as { properties?: unknown })?.properties
- : undefined
-
- if (languagesObj) {
- for (const langProp of languagesObj as Record[]) {
- const langKey =
- (langProp.key as { name?: string; value?: string })?.name ||
- (langProp.key as { name?: string; value?: string })?.value
-
- if (langKey === context.targetLanguage) {
- const codeProperty = (langProp.value as { properties?: Record[] })?.properties?.find(
- (p) => (p.key as { name?: string })?.name === "code"
- )
- const codeVarName = (codeProperty?.value as { name?: string })?.name
-
- if (codeVarName) {
- // Find the import statement for this variable
- const importRegex = new RegExp(`import\\s+${codeVarName}\\s+from\\s+['"](.+?)['"]`)
- const match = context.markdown.match(importRegex)
-
- if (match) {
- const importPath = match[1].split("?")[0] // Strip "?raw"
- const codeAbsPath = path.resolve(path.dirname(context.mdxAbsPath), importPath)
- let codeContent = fs.readFileSync(codeAbsPath, "utf-8")
-
- // Strip highlighter comments
- codeContent = codeContent
- .split("\n")
- .map((line) => line.replace(/\s*\/\/\s*highlight-(line|start|end)/, ""))
- .join("\n")
-
- // Infer language from file extension
- const fileExt = path.extname(codeAbsPath).slice(1)
- const lang = fileExt || context.targetLanguage
-
- // Create a code block for this language
- const newNodes: Node[] = []
- newNodes.push({
- type: "code",
- lang,
- value: codeContent.trim(),
- } as Literal)
-
- parent.children.splice(index, 1, ...newNodes)
- return index + newNodes.length
- }
- }
- break
+ const languagesAttr = node.attributes?.find((attribute) => attribute.name === "languages")
+ const expression = (
+ languagesAttr?.value as {
+ data?: { estree?: { body?: { expression?: EstreeNode }[] } }
+ }
+ )?.data?.estree?.body?.[0]?.expression
+ if (expression?.type !== "ObjectExpression") {
+ return dropNode(parent, index)
+ }
+
+ const codeNodes: Node[] = []
+ const imports = readStaticDefaultImports(context.markdown)
+ for (const languageProperty of expression.properties || []) {
+ if (languageProperty.type !== "Property" || languageProperty.computed) continue
+ const language =
+ languageProperty.key?.type === "Identifier"
+ ? languageProperty.key.name
+ : languageProperty.key?.type === "Literal" && typeof languageProperty.key.value === "string"
+ ? languageProperty.key.value
+ : undefined
+ if (!language || (context.targetLanguage && language !== context.targetLanguage)) continue
+
+ const languageConfig = languageProperty.value as EstreeNode
+ if (languageConfig?.type !== "ObjectExpression") continue
+ const codeProperty = (languageConfig.properties || []).find((property) => {
+ if (property.type !== "Property" || property.computed) return false
+ return (
+ (property.key?.type === "Identifier" && property.key.name === "code") ||
+ (property.key?.type === "Literal" && property.key.value === "code")
+ )
+ })
+ const codeExpression = codeProperty?.value as EstreeNode | undefined
+ let code: string | undefined
+ let codeLanguage = language
+
+ if (codeExpression?.type === "Identifier" && codeExpression.name) {
+ const importPath = imports.get(codeExpression.name)?.split("?")[0]
+ if (importPath) {
+ const codePath = resolveExistingWithin(
+ process.cwd(),
+ path.resolve(path.dirname(context.mdxAbsPath), importPath)
+ )
+ if (codePath) {
+ code = fs.readFileSync(codePath, "utf-8")
+ codeLanguage = path.extname(codePath).slice(1) || language
}
}
+ } else {
+ const staticCode = staticEstreeValue(codeExpression)
+ if (typeof staticCode === "string") code = staticCode
}
+
+ if (code !== undefined) {
+ codeNodes.push({
+ type: "code",
+ lang: codeLanguage,
+ value: stripHighlighterComments(code).trim(),
+ } as Literal)
+ }
+ }
+
+ if (codeNodes.length === 0) {
+ return dropNode(parent, index)
}
+ parent.children.splice(index, 1, ...codeNodes)
+ return index + codeNodes.length
} catch (e) {
console.warn(`Failed to process CodeHighlightBlockMulti in ${context.mdxAbsPath}:`, e)
+ return dropNode(parent, index)
}
}
@@ -294,45 +441,31 @@ export function handleDiv(node: MdxJsxNode, parent: Parent, index: number): numb
*/
export function handleAside(node: MdxJsxNode, parent: Parent, index: number, context: ComponentContext): number | void {
try {
- const typeAttr = node.attributes?.find((a) => a.name === "type")
- const titleAttr = node.attributes?.find((a) => a.name === "title")
-
- const type = typeof typeAttr?.value === "string" ? typeAttr.value.toUpperCase() : "NOTE"
- const title = typeof titleAttr?.value === "string" ? titleAttr.value : ""
-
- // Get children content
- const children = (node as Parent).children || []
-
- if (children.length === 0) {
- return
+ const typeValue = staticAttribute(node, "type")
+ const titleValue = staticAttribute(node, "title")
+ if (
+ typeValue === NON_STATIC ||
+ (typeValue !== undefined && typeof typeValue !== "string") ||
+ titleValue === NON_STATIC ||
+ (titleValue !== undefined && typeof titleValue !== "string")
+ ) {
+ return dropNode(parent, index)
}
-
- // Create blockquote header
- const header = title ? `**${type}: ${title}**` : `**${type}**`
-
- // Create new nodes for blockquote
- const newNodes: Node[] = []
-
- // Add blockquote paragraph with header
- newNodes.push({
+ const type = typeof typeValue === "string" ? typeValue.toUpperCase() : "NOTE"
+ const title = typeof titleValue === "string" ? titleValue : ""
+ const header = title ? `${type}: ${title}` : type
+ const blockquote = {
type: "blockquote",
children: [
- {
- type: "paragraph",
- children: [{ type: "text", value: header } as Literal],
- } as Parent,
- {
- type: "paragraph",
- children: [{ type: "text", value: "" } as Literal],
- } as Parent,
- ...children,
+ paragraphNode([{ type: "strong", children: [textNode(header)] } as Parent]),
+ ...((node as Parent).children || []),
],
- } as Parent)
-
- parent.children.splice(index, 1, ...newNodes)
- return index + newNodes.length
+ } as Parent
+ parent.children.splice(index, 1, blockquote)
+ return index
} catch (e) {
console.warn(`Failed to process Aside in ${context.mdxAbsPath}:`, e)
+ return dropNode(parent, index)
}
}
@@ -428,9 +561,10 @@ export function handleCodeSample(
const possiblePaths = [publicPath, path.resolve(src), path.join(process.cwd(), "src", src)]
let codeContent: string | null = null
- for (const p of possiblePaths) {
- if (fs.existsSync(p)) {
- codeContent = fs.readFileSync(p, "utf-8")
+ for (const candidate of possiblePaths) {
+ const safePath = resolveExistingWithin(process.cwd(), candidate)
+ if (safePath) {
+ codeContent = fs.readFileSync(safePath, "utf-8")
break
}
}
@@ -466,18 +600,12 @@ export function handleCodeSample(
/**
* Handle Billing component - generate markdown table with CCIP network fees
- * @param node - AST node
* @param parent - Parent node
* @param index - Index in parent's children
* @param context - Component context
* @returns New index or void
*/
-export function handleBilling(
- node: MdxJsxNode,
- parent: Parent,
- index: number,
- context: ComponentContext
-): number | void {
+export function handleBilling(parent: Parent, index: number, context: ComponentContext): number | void {
try {
// Calculate fees using the same logic as Billing.astro
const lockAndUnlockAllLanes = calculateNetworkFeesForTokenMechanismDirect(TokenMechanism.LockAndUnlock, "allLanes")
@@ -545,3 +673,289 @@ export function handleBilling(
} as Parent
}
}
+
+export function handlePageTabs(node: MdxJsxNode, parent: Parent, index: number): number | void {
+ const pages = staticAttribute(node, "pages")
+ const showHeader = staticAttribute(node, "showHeader")
+ const headerTitle = staticAttribute(node, "headerTitle")
+ if (
+ pages === NON_STATIC ||
+ !Array.isArray(pages) ||
+ showHeader === NON_STATIC ||
+ (showHeader !== undefined && typeof showHeader !== "boolean") ||
+ headerTitle === NON_STATIC ||
+ (headerTitle !== undefined && typeof headerTitle !== "string")
+ ) {
+ return dropNode(parent, index)
+ }
+
+ const links: { label: string; url: string }[] = []
+ for (const pageOrGroup of pages) {
+ const group = Array.isArray(pageOrGroup) ? pageOrGroup : [pageOrGroup]
+ if (group.length === 0) {
+ return dropNode(parent, index)
+ }
+ const groupPages: { name: string; url: string }[] = []
+ for (const page of group) {
+ if (
+ !page ||
+ Array.isArray(page) ||
+ typeof page !== "object" ||
+ typeof page.name !== "string" ||
+ typeof page.url !== "string"
+ ) {
+ return dropNode(parent, index)
+ }
+ groupPages.push({ name: page.name, url: page.url })
+ }
+ links.push({ label: groupPages.map((page) => page.name).join(" / "), url: groupPages[0].url })
+ }
+
+ const replacement: Node[] = []
+ if (showHeader !== false)
+ replacement.push(headingNode(2, typeof headerTitle === "string" ? headerTitle : "Guide Versions"))
+ if (links.length > 0) {
+ replacement.push({
+ type: "list",
+ ordered: false,
+ children: links.map(
+ ({ label, url }) =>
+ ({
+ type: "listItem",
+ children: [paragraphNode([linkNode(label, url)])],
+ }) as Parent
+ ),
+ } as Parent)
+ }
+ parent.children.splice(index, 1, ...replacement)
+ return index + replacement.length
+}
+
+type SlottedElement = { node: MdxJsxNode; parent: Parent; slot: string }
+
+function slottedElements(node: Parent): SlottedElement[] {
+ const elements: SlottedElement[] = []
+ const collect = (parent: Parent) => {
+ for (const child of parent.children || []) {
+ if (child.type === "mdxJsxFlowElement" || child.type === "mdxJsxTextElement") {
+ const slot = staticAttribute(child as MdxJsxNode, "slot")
+ if (typeof slot === "string") elements.push({ node: child as MdxJsxNode, parent, slot })
+ continue
+ }
+ if ((child as Parent).children) collect(child as Parent)
+ }
+ }
+ collect(node)
+ return elements
+}
+
+export function handleTabs(node: MdxJsxNode, parent: Parent, index: number): number | void {
+ const tabs: { key: string; label: string }[] = []
+ const panels = new Map()
+
+ for (const { node: child, slot } of slottedElements(node as Parent)) {
+ if (slot.startsWith("tab.")) {
+ const label = staticChildrenText(child as Parent)
+ if (label !== NON_STATIC && label) tabs.push({ key: slot.slice(4), label })
+ } else if (slot.startsWith("panel.")) {
+ panels.set(slot.slice(6), (child as Parent).children || [])
+ }
+ }
+
+ const replacement: Node[] = []
+ for (const tab of tabs) {
+ const panel = panels.get(tab.key)
+ if (!panel) continue
+ replacement.push(headingNode(3, tab.label), ...panel)
+ }
+ if (replacement.length === 0) {
+ return dropNode(parent, index)
+ }
+ parent.children.splice(index, 1, ...replacement)
+ return index
+}
+
+export function handlePackageManagerTabs(node: MdxJsxNode, parent: Parent, index: number): number | void {
+ const slots = slottedElements(node as Parent)
+ const replacement: Node[] = []
+ for (const manager of ["npm", "yarn"]) {
+ const content = slots.find(({ slot }) => slot === manager)?.node as Parent | undefined
+ if (content) replacement.push(headingNode(3, manager), ...(content.children || []))
+ }
+ if (replacement.length === 0) {
+ return dropNode(parent, index)
+ }
+ parent.children.splice(index, 1, ...replacement)
+ return index
+}
+
+export function handleFragment(node: MdxJsxNode, parent: Parent, index: number): number | void {
+ const children = (node as Parent).children || []
+ parent.children.splice(index, 1, ...children)
+ return index
+}
+
+export function handleAccordion(node: MdxJsxNode, parent: Parent, index: number): number | void {
+ const title = staticAttribute(node, "title")
+ const number = staticAttribute(node, "number")
+ const titleSlot = slottedElements(node as Parent).find(({ slot }) => slot === "title")
+ const slotTitle = titleSlot ? staticChildrenText(titleSlot.node as Parent) : undefined
+ if (
+ title === NON_STATIC ||
+ typeof title !== "string" ||
+ number === NON_STATIC ||
+ (number !== undefined && typeof number !== "number") ||
+ slotTitle === NON_STATIC ||
+ (titleSlot && !slotTitle)
+ ) {
+ return dropNode(parent, index)
+ }
+ if (titleSlot) {
+ titleSlot.parent.children.splice(titleSlot.parent.children.indexOf(titleSlot.node), 1)
+ }
+ const label = `${typeof number === "number" ? `${number}. ` : ""}${slotTitle || title}`
+ const replacement: Node[] = [headingNode(3, label), ...((node as Parent).children || [])]
+ parent.children.splice(index, 1, ...replacement)
+ return index
+}
+
+export function handleAddress(node: MdxJsxNode, parent: Parent, index: number): number | void {
+ const contractUrl = staticAttribute(node, "contractUrl")
+ const address = staticAttribute(node, "address")
+ const endLength = staticAttribute(node, "endLength")
+ if (
+ contractUrl === NON_STATIC ||
+ typeof contractUrl !== "string" ||
+ address === NON_STATIC ||
+ (address !== undefined && typeof address !== "string") ||
+ endLength === NON_STATIC ||
+ (endLength !== undefined && (typeof endLength !== "number" || !Number.isInteger(endLength) || endLength < 0))
+ ) {
+ return dropNode(parent, index)
+ }
+
+ const value = typeof address === "string" && address ? address : contractUrl.split("/").pop() || contractUrl
+ const display =
+ typeof endLength === "number" && endLength > 0
+ ? `${value.slice(0, endLength + 2)}...${value.slice(-endLength)}`
+ : value
+ parent.children[index] = linkNode(display, contractUrl)
+}
+
+export function handleCallout(
+ node: MdxJsxNode,
+ parent: Parent,
+ index: number,
+ context: ComponentContext
+): number | void {
+ return handleAside(node, parent, index, context)
+}
+
+const SELECTOR_COMPONENTS = {
+ AnyApiCallout: { astro: "src/features/any-api/common/AnyApiCallout.astro", attribute: "callout" },
+ FeedsCommonCallout: { astro: "src/features/feeds/callouts/FeedsCommonCallout.astro", attribute: "callout" },
+ ResourcesCallout: { astro: "src/features/resources/callouts/ResourcesCallout.astro", attribute: "callout" },
+ DataStreams: { astro: "src/features/data-streams/common/DataStreams.astro", attribute: "section" },
+} as const
+
+function handleAstroSelector(
+ node: MdxJsxNode,
+ parent: Parent,
+ index: number,
+ context: ComponentContext,
+ componentName: keyof typeof SELECTOR_COMPONENTS
+): number | void {
+ const config = SELECTOR_COMPONENTS[componentName]
+ const selector = staticAttribute(node, config.attribute)
+ if (typeof selector !== "string") {
+ return dropNode(parent, index)
+ }
+
+ try {
+ const astroPath = resolveExistingWithin(process.cwd(), config.astro)
+ if (!astroPath) {
+ return dropNode(parent, index)
+ }
+ const astroDirectory = path.dirname(astroPath)
+ const source = fs.readFileSync(astroPath, "utf-8")
+ const imports = readStaticDefaultImports(source)
+ const conditions = readStaticJsxSelectorConditions(source, config.attribute)
+
+ const importPath = imports.get(conditions.get(selector) || "")
+ const markdownPath = importPath && resolveExistingWithin(astroDirectory, importPath)
+ if (!markdownPath || path.extname(markdownPath) !== ".mdx") {
+ return dropNode(parent, index)
+ }
+ const markdown = removeLeadingMdxFrontmatter(fs.readFileSync(markdownPath, "utf-8"))
+ const tree = context.processor.parse(markdown) as Parent
+ parent.children.splice(index, 1, ...(tree.children || []))
+ return index
+ } catch (e) {
+ console.warn(`Failed to process ${componentName} in ${context.mdxAbsPath}:`, e)
+ return dropNode(parent, index)
+ }
+}
+
+export function handleAnyApiCallout(
+ node: MdxJsxNode,
+ parent: Parent,
+ index: number,
+ context: ComponentContext
+): number | void {
+ return handleAstroSelector(node, parent, index, context, "AnyApiCallout")
+}
+
+export function handleFeedsCommonCallout(
+ node: MdxJsxNode,
+ parent: Parent,
+ index: number,
+ context: ComponentContext
+): number | void {
+ return handleAstroSelector(node, parent, index, context, "FeedsCommonCallout")
+}
+
+export function handleResourcesCallout(
+ node: MdxJsxNode,
+ parent: Parent,
+ index: number,
+ context: ComponentContext
+): number | void {
+ return handleAstroSelector(node, parent, index, context, "ResourcesCallout")
+}
+
+export function handleDataStreams(
+ node: MdxJsxNode,
+ parent: Parent,
+ index: number,
+ context: ComponentContext
+): number | void {
+ return handleAstroSelector(node, parent, index, context, "DataStreams")
+}
+
+function escapeTableCell(value: string): string {
+ return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ")
+}
+
+export function handleSchemaFieldsTable(
+ node: MdxJsxNode,
+ parent: Parent,
+ index: number,
+ context: ComponentContext
+): number | void {
+ const schema = staticAttribute(node, "schema")
+ const definition = typeof schema === "string" ? REPORT_SCHEMA_DEFINITIONS[schema] : undefined
+ if (!definition) {
+ return dropNode(parent, index)
+ }
+ const rows = [
+ "| Field | Type | Description |",
+ "| --- | --- | --- |",
+ ...definition.fields.map((field) => {
+ const description = `${field.description}${field.link ? ` — [${field.link.label}](${field.link.href})` : ""}`
+ return `| \`${escapeTableCell(field.field)}\` | \`${escapeTableCell(field.type)}\` | ${escapeTableCell(description)} |`
+ }),
+ ]
+ const tree = context.processor.parse(rows.join("\n")) as Parent
+ parent.children.splice(index, 1, ...(tree.children || []))
+ return index + (tree.children?.length || 0)
+}
diff --git a/src/lib/markdown/index.ts b/src/lib/markdown/index.ts
index f3342c8a4fa..a690d676a22 100644
--- a/src/lib/markdown/index.ts
+++ b/src/lib/markdown/index.ts
@@ -4,3 +4,5 @@
*/
export * from "./formatters.js"
+export * from "./buildMarkdownArtifact.js"
+export type { MarkdownArtifact } from "./types.js"
diff --git a/src/lib/markdown/sourceScanners.ts b/src/lib/markdown/sourceScanners.ts
new file mode 100644
index 00000000000..71c8488f208
--- /dev/null
+++ b/src/lib/markdown/sourceScanners.ts
@@ -0,0 +1,190 @@
+const HIGHLIGHTER_MARKERS = ["highlight-line", "highlight-start", "highlight-end"] as const
+
+function isWhitespace(code: number): boolean {
+ return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32
+}
+
+function isIdentifierStart(code: number): boolean {
+ return code === 36 || code === 95 || (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
+}
+
+function isIdentifierPart(code: number): boolean {
+ return isIdentifierStart(code) || (code >= 48 && code <= 57)
+}
+
+function skipWhitespace(source: string, cursor: number): number {
+ while (cursor < source.length && isWhitespace(source.charCodeAt(cursor))) cursor += 1
+ return cursor
+}
+
+function findIdentifierToken(source: string, token: string, cursor: number): number {
+ while (cursor < source.length) {
+ const start = source.indexOf(token, cursor)
+ if (start < 0) return -1
+ const end = start + token.length
+ if (
+ (start === 0 || !isIdentifierPart(source.charCodeAt(start - 1))) &&
+ (end === source.length || !isIdentifierPart(source.charCodeAt(end)))
+ ) {
+ return start
+ }
+ cursor = end
+ }
+ return -1
+}
+
+function readIdentifier(source: string, cursor: number): { value: string; end: number } | undefined {
+ if (!isIdentifierStart(source.charCodeAt(cursor))) return
+ const start = cursor
+ cursor += 1
+ while (cursor < source.length && isIdentifierPart(source.charCodeAt(cursor))) cursor += 1
+ return { value: source.slice(start, cursor), end: cursor }
+}
+
+function quotedEndOnLine(source: string, cursor: number, quote: number): number {
+ while (cursor < source.length && source.charCodeAt(cursor) !== 10) {
+ if (source.charCodeAt(cursor) === quote) return cursor
+ cursor += 1
+ }
+ return -1
+}
+
+export function readStaticDefaultImports(source: string): Map {
+ const imports = new Map()
+ let cursor = 0
+
+ while (cursor < source.length) {
+ const start = findIdentifierToken(source, "import", cursor)
+ if (start < 0) break
+ cursor = start + "import".length
+ if (!isWhitespace(source.charCodeAt(cursor))) continue
+
+ cursor = skipWhitespace(source, cursor)
+ const identifier = readIdentifier(source, cursor)
+ if (!identifier) continue
+ cursor = identifier.end
+ if (!isWhitespace(source.charCodeAt(cursor))) continue
+
+ cursor = skipWhitespace(source, cursor)
+ if (!source.startsWith("from", cursor) || isIdentifierPart(source.charCodeAt(cursor + "from".length))) {
+ continue
+ }
+ cursor += "from".length
+ if (!isWhitespace(source.charCodeAt(cursor))) continue
+
+ cursor = skipWhitespace(source, cursor)
+ const quote = source.charCodeAt(cursor)
+ if (quote !== 34 && quote !== 39) continue
+ cursor += 1
+ const end = quotedEndOnLine(source, cursor, quote)
+ if (end < 0) {
+ const lineEnd = source.indexOf("\n", cursor)
+ cursor = lineEnd < 0 ? source.length : lineEnd + 1
+ continue
+ }
+ if (end > cursor) imports.set(identifier.value, source.slice(cursor, end))
+ cursor = end + 1
+ }
+
+ return imports
+}
+
+export function readStaticJsxSelectorConditions(source: string, attribute: string): Map {
+ const conditions = new Map()
+ if (attribute.length === 0) return conditions
+ let cursor = 0
+
+ while (cursor < source.length) {
+ const start = findIdentifierToken(source, attribute, cursor)
+ if (start < 0) break
+ cursor = start + attribute.length
+ cursor = skipWhitespace(source, cursor)
+ if (!source.startsWith("===", cursor)) continue
+
+ cursor += 3
+ cursor = skipWhitespace(source, cursor)
+ const quote = source.charCodeAt(cursor)
+ if (quote !== 34 && quote !== 39) continue
+ cursor += 1
+ const selectorEnd = quotedEndOnLine(source, cursor, quote)
+ if (selectorEnd < 0) {
+ const lineEnd = source.indexOf("\n", cursor)
+ cursor = lineEnd < 0 ? source.length : lineEnd + 1
+ continue
+ }
+ const selector = source.slice(cursor, selectorEnd)
+ cursor = skipWhitespace(source, selectorEnd + 1)
+ if (!source.startsWith("&&", cursor)) continue
+
+ cursor += 2
+ cursor = skipWhitespace(source, cursor)
+ if (source.charCodeAt(cursor) !== 60) continue
+ cursor += 1
+ const component = readIdentifier(source, cursor)
+ if (!component) continue
+ cursor = component.end
+ if (selector.length > 0) conditions.set(selector, component.value)
+ }
+
+ return conditions
+}
+
+function isFenceLine(source: string, start: number, end: number): boolean {
+ if (source.charCodeAt(start) !== 45 || source.charCodeAt(start + 1) !== 45 || source.charCodeAt(start + 2) !== 45) {
+ return false
+ }
+ for (let cursor = start + 3; cursor < end; cursor += 1) {
+ if (!isWhitespace(source.charCodeAt(cursor))) return false
+ }
+ return true
+}
+
+export function removeLeadingMdxFrontmatter(source: string): string {
+ const firstLineEnd = source.indexOf("\n")
+ if (firstLineEnd < 0 || !isFenceLine(source, 0, firstLineEnd)) return source
+
+ let cursor = firstLineEnd + 1
+ while (cursor < source.length) {
+ const lineEnd = source.indexOf("\n", cursor)
+ if (lineEnd < 0) return source
+ if (isFenceLine(source, cursor, lineEnd)) return source.slice(lineEnd + 1)
+ cursor = lineEnd + 1
+ }
+ return source
+}
+
+function highlighterMarkerLength(line: string, cursor: number): number {
+ for (const marker of HIGHLIGHTER_MARKERS) {
+ if (line.startsWith(marker, cursor)) return marker.length
+ }
+ return 0
+}
+
+function stripHighlighterCommentLine(line: string): string {
+ let cursor = 0
+ let whitespaceStart = 0
+
+ while (cursor < line.length) {
+ if (isWhitespace(line.charCodeAt(cursor))) {
+ cursor += 1
+ continue
+ }
+ if (line.charCodeAt(cursor) === 47 && line.charCodeAt(cursor + 1) === 47) {
+ const markerStart = skipWhitespace(line, cursor + 2)
+ const markerLength = highlighterMarkerLength(line, markerStart)
+ if (markerLength > 0) {
+ return line.slice(0, whitespaceStart) + line.slice(markerStart + markerLength)
+ }
+ }
+ cursor += 1
+ whitespaceStart = cursor
+ }
+
+ return line
+}
+
+export function stripHighlighterComments(code: string): string {
+ const chunks: string[] = []
+ for (const line of code.split("\n")) chunks.push(stripHighlighterCommentLine(line))
+ return chunks.join("\n")
+}
diff --git a/src/lib/markdown/transformMarkdown.ts b/src/lib/markdown/transformMarkdown.ts
index 58c20f5da64..d9e51cd8df7 100644
--- a/src/lib/markdown/transformMarkdown.ts
+++ b/src/lib/markdown/transformMarkdown.ts
@@ -20,63 +20,27 @@ import {
handleClickToZoom,
handleCodeSample,
handleBilling,
+ handlePageTabs,
+ handleTabs,
+ handlePackageManagerTabs,
+ handleFragment,
+ handleAccordion,
+ handleAddress,
+ handleCallout,
+ handleAnyApiCallout,
+ handleFeedsCommonCallout,
+ handleResourcesCallout,
+ handleDataStreams,
+ handleSchemaFieldsTable,
loadCcipCommonMapping,
} from "./componentHandlers.js"
import fs from "fs"
import path from "path"
-
-/**
- * Convert Aside components to markdown blockquotes
- * Handles multi-line Aside tags by converting them to blockquote format
- * Preserves Asides with nested JSX components (they'll be handled by AST or remain as-is)
- * @param content - Markdown content that may contain Aside components
- * @returns Content with simple Aside tags converted to blockquotes
- */
-function convertAsidesToBlockquotes(content: string): string {
- // Match multi-line Aside components
- const asideRegex = /