Skip to content

feat: Render JSX components in headings (ToC + nav sidebar)#1929

Merged
dan-lee merged 4 commits intomainfrom
feat/rich-jsx-headings
Feb 18, 2026
Merged

feat: Render JSX components in headings (ToC + nav sidebar)#1929
dan-lee merged 4 commits intomainfrom
feat/rich-jsx-headings

Conversation

@dan-lee
Copy link
Contributor

@dan-lee dan-lee commented Feb 4, 2026

  • Headings with JSX (Badge, etc) now render in both ToC and nav sidebar
  • New RichText component converts HAST/MDX AST nodes to React
  • New rehype plugins extract ToC with JSX preserved
  • Nav sidebar parses MDX h1s at build time via mdast/micromark

Example from Cosmo Cargo:

Fix #1440

Summary by CodeRabbit

  • New Features

    • JSX components can render as rich content in navigation items, headings, and table of contents
    • Alert and Badge components exposed for use in MDX docs
  • Documentation

    • Added "JSX in Headings" guidance and MDX component registration notes
    • New "Quantum Express" Cosmo Cargo example with usage and pricing info
    • Updated build, dev, debugging, and TypeScript style guidance

Copilot AI review requested due to automatic review settings February 4, 2026 13:25
@dan-lee dan-lee added the feature Feature label Feb 4, 2026
@vercel
Copy link

vercel bot commented Feb 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
zudoku-cosmo-cargo Ready Ready Preview, Comment Feb 18, 2026 1:08pm
zudoku-dev Ready Ready Preview, Comment Feb 18, 2026 1:08pm

Request Review

@github-actions
Copy link

github-actions bot commented Feb 4, 2026

Preview build of published Zudoku package for commit 317ee27.

See the deployment at: https://1df8d923.cosmocargo-public-package.pages.dev

Note

This is a preview of the Cosmo Cargo example using the Zudoku package published to a local registry to ensure it'll be working when published to the public NPM registry.

Last updated: 2026-02-18T13:12:52.550Z

This comment was marked as outdated.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 22 changed files in this pull request and generated 4 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@coderabbitai
Copy link

coderabbitai bot commented Feb 13, 2026

📝 Walkthrough

Walkthrough

Adds support for preserving and rendering JSX content inside headings by extracting rich H1/TOC AST nodes, exposing them through navigation metadata, updating TOC/navigation components to render rich content via a new RichText renderer, and replacing the frontmatter plugin with a doc-metadata plugin that tracks document metadata and H1 changes.

Changes

Cohort / File(s) Summary
Rehype MDX TOC plugins
packages/zudoku/src/vite/mdx/rehype-extract-toc-with-jsx.ts, packages/zudoku/src/vite/mdx/rehype-extract-toc-with-jsx-export.ts
New rehype plugin(s) that extract TOC entries and preserve JSX-rich AST nodes (adds TocEntry.rich and exports TOC as MDX export).
Vite plugins
packages/zudoku/src/vite/plugin-doc-metadata.ts, packages/zudoku/src/vite/plugin-frontmatter.ts (removed), packages/zudoku/src/vite/plugin.ts, packages/zudoku/src/vite/plugin-mdx.ts
Replaced frontmatter plugin with a new doc-metadata plugin that collects frontmatter and top-level H1 and watches file changes; plugin-mdx switched TOC plugins to the new JSX-aware variants.
Navigation schema & resolver
packages/zudoku/src/config/validators/NavigationSchema.ts
Added MDX parsing to extract rich H1 content and exposed rich?: RootContent[] on NavigationDoc; label resolution considers rich H1.
Navigation & TOC UI
packages/zudoku/src/lib/components/navigation/NavigationItem.tsx, packages/zudoku/src/lib/components/navigation/Toc.tsx, packages/zudoku/src/lib/components/navigation/utils.ts
Render TOC/navigation entries using text + optional rich AST instead of plain value; skip filter nodes when finding first matching path.
RichText renderer & MDX components
packages/zudoku/src/lib/util/hastToJsx.tsx, packages/zudoku/src/lib/util/MdxComponents.tsx
New RichText component converts MDX HAST/MDX JSX AST to JSX using MDX components; added Alert and Badge to MDX component map.
Plugin / MDX integration types
packages/zudoku/src/lib/plugins/markdown/index.tsx, packages/zudoku/src/lib/plugins/markdown/MdxPage.tsx, packages/zudoku/src/lib/plugins/openapi/SchemaList.tsx
Switched TOC type/source to local JSX-aware module; adjusted page title and SchemaList to use text field from TOC entries.
Examples & docs
examples/cosmo-cargo/pages/quantum-express.mdx, examples/cosmo-cargo/zudoku.config.tsx, AGENT.md, docs/pages/docs/markdown/mdx.md, docs/pages/docs/components/badge.mdx
Added Quantum Express example page and registered MDX components/trademark in example config; documentation updated to note JSX in headings and minor doc edits.
Package changes
packages/zudoku/package.json, package.json
Added several MDX/AST dependencies and removed an unused patched dependency entry.
Small plugin/component tweaks
packages/zudoku/src/lib/plugins/markdown/index.tsx, packages/zudoku/src/lib/plugins/markdown/MdxPage.tsx, packages/zudoku/src/lib/plugins/openapi/SchemaList.tsx
Type/import source changes to consume the new TOC type and minor mapping adjustments to use text instead of value.

Sequence Diagram(s)

sequenceDiagram
    participant Dev as Developer
    participant Vite as Vite/MDX
    participant Rehype as Rehype TOC Extractor
    participant Plugin as Doc-Metadata Plugin
    participant Nav as NavigationResolver
    participant UI as Navigation/TOC Components
    participant Rich as RichText Renderer

    Dev->>Vite: Load MDX file with JSX in H1
    Vite->>Rehype: Parse AST, extract headings (mark rich nodes)
    Rehype-->>Vite: Attach Toc (with rich AST) to vfile.data
    Vite->>Plugin: Collect frontmatter + top-level H1 (rich)
    Plugin-->>Nav: Provide metadata + rich H1
    Nav->>UI: Emit NavigationDoc with optional `rich` content
    UI->>Rich: Render `rich` AST nodes via RichText
    Rich->>UI: Return rendered JSX components
    UI-->>Dev: Display JSX inside headings/TOC/sidenav
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested labels

maintenance

Suggested reviewers

  • mosch

Poem

🐰
I chewed the AST and found a clue,
Headings now can wear a view.
Rehype danced and TOC sang true,
Rich JSX blooms in sidebar too —
Hops of joy for docs anew! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title clearly and specifically describes the main feature: rendering JSX components in headings for both Table of Contents and navigation sidebar.
Linked Issues check ✅ Passed All coding requirements from issue #1440 are met: JSX components now render in ToC and navigation sidebar through new RichText component, rehype plugins, and mdast parsing.
Out of Scope Changes check ✅ Passed All changes directly support the objective of rendering JSX in headings; includes new MDX utilities, rehype plugins, documentation updates showing the feature, and example implementation in cosmo-cargo.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/rich-jsx-headings

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/zudoku/src/config/validators/NavigationSchema.ts`:
- Around line 134-139: The code in NavigationSchema.ts unsafely casts mdast
children (variable children of type MdxPhrasingContent[]) to hast RootContent[]
when returning rich for hasJsx, which masks AST differences; either explicitly
convert mdast->hast using an available utility or, if safe in practice, add a
clear explanatory comment next to the cast (referencing hasJsx, children, and
RichText) documenting why the cast is acceptable; also replace the silent catch
with error handling that logs or rethrows the caught error so MDX parsing issues
aren't hidden (e.g., surface the error via processLogger or throw a wrapped
error).
🧹 Nitpick comments (7)
packages/zudoku/src/vite/plugin-doc-metadata.ts (3)

8-12: Consider adding error handling for file read failures.

If readFrontmatter throws (e.g., file deleted between glob scan and read, or permission issues), the error will propagate unhandled. This could crash the initial plugin setup or leave the watcher callback in a failed state.

🛡️ Proposed fix with error handling
 const serializeMetadata = async (filePath: string): Promise<string> => {
-  const fm = await readFrontmatter(filePath);
-  const h1 = fm.content.match(/^#\s+(.*)$/m)?.[1];
-  return JSON.stringify({ frontmatter: fm.data, h1 });
+  try {
+    const fm = await readFrontmatter(filePath);
+    const h1 = fm.content.match(/^#\s+(.*)$/m)?.[1];
+    return JSON.stringify({ frontmatter: fm.data, h1 });
+  } catch {
+    return JSON.stringify({ frontmatter: {}, h1: undefined });
+  }
 };

19-22: Glob ignore patterns may not exclude nested directories.

Simple strings like "node_modules" only match that exact directory at the cwd level. Files in nested node_modules directories (e.g., packages/foo/node_modules/) may still be included.

♻️ Use glob patterns for nested exclusion
     const files = await glob("**/*.{md,mdx}", {
       cwd: config.__meta.rootDir,
-      ignore: ["node_modules", "dist"],
+      ignore: ["**/node_modules/**", "**/dist/**"],
       absolute: true,
     });

33-43: File deletions are not handled.

When a file is deleted (unlink event), it remains in metadataMap. While this is a minor memory leak during dev sessions, stale entries could cause unexpected behavior if the navigation relies on this map's contents.

♻️ Handle file deletion
     server.watcher.on("all", async (event, filePath) => {
-      if (event !== "change" && event !== "add") return;
       if (!/\.mdx?$/.test(filePath)) return;
+      
+      if (event === "unlink") {
+        if (metadataMap.delete(filePath)) {
+          invalidateNavigation(server);
+          reload(server);
+        }
+        return;
+      }
+
+      if (event !== "change" && event !== "add") return;

       const metadata = await serializeMetadata(filePath);
packages/zudoku/src/lib/components/navigation/NavigationItem.tsx (1)

109-115: Consider truncation behavior for rich text items.

When item.rich is present, the RichText component renders without truncation or tooltip support. For plain labels, TruncatedLabel provides a tooltip when text overflows. Long JSX-rendered content could overflow without user feedback.

This may be intentional if rich content is expected to be short (badges, small components), but consider whether truncation/tooltip behavior should be consistent.

packages/zudoku/src/vite/mdx/rehype-extract-toc-with-jsx.ts (1)

32-33: Type assertion is safe, but clarify intent of hasRichContent check with comments.

The cast node.children as RootContent[] is semantically correct: ElementContent (Comment | Element | Text) is fully compatible with RootContent (Comment | Doctype | Element | Text). The Doctype node type in RootContent will never appear in element children, so the cast carries no safety risk.

However, the hasRichContent check on line 33 using child.type !== "text" will match both element and comment nodes. Consider whether HTML comments in headings should be treated as rich content, or whether comments should be explicitly excluded.

packages/zudoku/src/lib/util/MdxComponents.tsx (1)

3-3: Inconsistent import paths for UI components.

Alert is imported via package alias (zudoku/ui/Alert.js) while Badge uses a relative path (../ui/Badge.js). Consider using consistent import style for maintainability.

♻️ Suggested fix
-import { Alert } from "zudoku/ui/Alert.js";
+import { Alert } from "../ui/Alert.js";

Or alternatively, update Badge to use the package alias if that's the preferred convention.

Also applies to: 10-10

packages/zudoku/src/lib/util/hastToJsx.tsx (1)

27-40: Complex JSX attribute expressions are silently ignored.

When an MDX JSX element has attribute expressions (e.g., <Badge variant={someVariable}>), these are silently dropped since only primitive values and null are handled. This is likely intentional for the ToC/nav use case, but could lead to unexpected behavior if users expect dynamic expressions to work.

Consider adding a comment explaining this limitation, or logging a warning in development mode when expression attributes are encountered.

@dan-lee dan-lee force-pushed the feat/rich-jsx-headings branch from ea0c89b to 317ee27 Compare February 18, 2026 13:05
@dan-lee dan-lee enabled auto-merge (squash) February 18, 2026 13:06
@dan-lee dan-lee merged commit 5f073eb into main Feb 18, 2026
12 of 13 checks passed
@dan-lee dan-lee deleted the feat/rich-jsx-headings branch February 18, 2026 13:10
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/zudoku/src/config/validators/NavigationSchema.ts`:
- Around line 113-136: extractRichH1 only checks direct H1 children for JSX so
JSX wrapped inside emphasis/strong/link nodes is missed; update extractRichH1 to
perform a recursive/descendant search (e.g., add a helper like containsJsx(node)
that returns true if node isMdxJsxElement or any of its children contain JSX)
and replace the hasJsx = children.some(isMdxJsxElement) line with hasJsx =
children.some(child => containsJsx(child)); keep returning { label, rich:
children as RootContent[] } when hasJsx is true.

In `@packages/zudoku/src/lib/util/MdxComponents.tsx`:
- Line 10: The Badge component is imported via a relative path in
MdxComponents.tsx; replace the relative import of Badge (currently from
"../ui/Badge.js") with the shared module import from "zudoku/ui" so the file
imports Badge from the central UI package (keep the named import Badge and leave
usages unchanged). Ensure the import statement uses the exact module specifier
"zudoku/ui" and remove the old relative import.

In `@packages/zudoku/src/vite/mdx/rehype-extract-toc-with-jsx-export.ts`:
- Around line 9-11: Replace the plain Error thrown when an invalid export name
is detected with the project's custom ZudokuError: in the branch that checks
isIdentifierName(name) and currently does throw new Error(...), import or
reference ZudokuError and throw new ZudokuError(`Invalid identifier name:
${JSON.stringify(name)}`) (keep the original message content), so the check in
isIdentifierName(name) uses the project-specific error class; ensure the
ZudokuError import is added if not already present.

Comment on lines +113 to +136
// Extract rich H1 heading content from MDX. Returns AST nodes only when H1 contains JSX elements.
const extractRichH1 = (content: string) => {
try {
const mdast = fromMarkdown(content, {
extensions: [mdxjs()],
mdastExtensions: [mdxFromMarkdown()],
// biome-ignore lint/suspicious/noExplicitAny: mdast-util-from-markdown has type incompatibilities between versions
} as any);

const h1 = mdast.children.find(
(node): node is Heading => node.type === "heading" && node.depth === 1,
);

if (!h1) return undefined;

const children = h1.children as MdxPhrasingContent[];
const hasJsx = children.some(isMdxJsxElement);

// Extract all text content including from emphasis/strong/links
const label = mdastToString(h1).trim();

// Note: rich only contains MDAST nodes. RichText handles text and mdxJsxTextElement,
// but markdown formatting (strong/emphasis/link) in H1 won't render styled.
return hasJsx ? { label, rich: children as RootContent[] } : { label };
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Detect JSX nested within formatted heading nodes.

hasJsx only checks direct H1 children. If JSX is wrapped by emphasis/strong/link nodes, rich won’t be set and the nav/ToC will still lose JSX. A recursive check avoids that gap.

Proposed fix
 const isMdxJsxElement = (node: MdxPhrasingContent): node is MdxJsxTextElement =>
   node.type === "mdxJsxTextElement";
 
+const containsJsx = (node: MdxPhrasingContent): boolean => {
+  if (isMdxJsxElement(node)) return true;
+  if ("children" in node && Array.isArray(node.children)) {
+    return node.children.some((child) =>
+      containsJsx(child as MdxPhrasingContent),
+    );
+  }
+  return false;
+};
+
 const mdastToString = (node: MdxPhrasingContent | Heading): string => {
   if ("value" in node && typeof node.value === "string") return node.value;
   if ("children" in node && Array.isArray(node.children)) {
     return node.children
       .map((c) => mdastToString(c as MdxPhrasingContent))
       .join("");
   }
 
   return "";
 };
@@
     if (!h1) return undefined;
 
     const children = h1.children as MdxPhrasingContent[];
-    const hasJsx = children.some(isMdxJsxElement);
+    const hasJsx = children.some(containsJsx);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/zudoku/src/config/validators/NavigationSchema.ts` around lines 113 -
136, extractRichH1 only checks direct H1 children for JSX so JSX wrapped inside
emphasis/strong/link nodes is missed; update extractRichH1 to perform a
recursive/descendant search (e.g., add a helper like containsJsx(node) that
returns true if node isMdxJsxElement or any of its children contain JSX) and
replace the hasJsx = children.some(isMdxJsxElement) line with hasJsx =
children.some(child => containsJsx(child)); keep returning { label, rich:
children as RootContent[] } when hasJsx is true.

import { InlineCode } from "../components/InlineCode.js";
import { Mermaid } from "../components/Mermaid.js";
import { HIGHLIGHT_CODE_BLOCK_CLASS } from "../shiki.js";
import { Badge } from "../ui/Badge.js";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Align Badge import with zudoku/ui.

Badge is pulled via a relative path; use the zudoku/ui module import for consistency with the UI import guideline.

Suggested fix
-import { Badge } from "../ui/Badge.js";
+import { Badge } from "zudoku/ui/Badge.js";

As per coding guidelines: Use UI components from the zudoku/ui module based on shadcn/ui.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { Badge } from "../ui/Badge.js";
import { Badge } from "zudoku/ui/Badge.js";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/zudoku/src/lib/util/MdxComponents.tsx` at line 10, The Badge
component is imported via a relative path in MdxComponents.tsx; replace the
relative import of Badge (currently from "../ui/Badge.js") with the shared
module import from "zudoku/ui" so the file imports Badge from the central UI
package (keep the named import Badge and leave usages unchanged). Ensure the
import statement uses the exact module specifier "zudoku/ui" and remove the old
relative import.

Comment on lines +9 to +11
if (!isIdentifierName(name)) {
throw new Error(`Invalid identifier name: ${JSON.stringify(name)}`);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use ZudokuError for invalid export names.

Guidelines require custom errors to throw/extend ZudokuError; please swap the thrown error accordingly.
As per coding guidelines: Throw and/or extend ZudokuError for custom errors.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/zudoku/src/vite/mdx/rehype-extract-toc-with-jsx-export.ts` around
lines 9 - 11, Replace the plain Error thrown when an invalid export name is
detected with the project's custom ZudokuError: in the branch that checks
isIdentifierName(name) and currently does throw new Error(...), import or
reference ZudokuError and throw new ZudokuError(`Invalid identifier name:
${JSON.stringify(name)}`) (keep the original message content), so the check in
isIdentifierName(name) uses the project-specific error class; ensure the
ZudokuError import is added if not already present.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

String/span JSX component not rendered correctly in TOC and sidenav

2 participants

Comments