Skip to content

Commit e23cc24

Browse files
committed
feat: Render JSX components in headings (ToC + nav sidebar)
1 parent 886f98b commit e23cc24

File tree

22 files changed

+392
-407
lines changed

22 files changed

+392
-407
lines changed

AGENT.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010
- For Markdown and YAML files, run `pnpm prettier --write '**/*.{md,mdx,yml,yaml}'` to fix
1111
formatting errors
1212
- For all other files, run `pnpm biome check --write {files}` to fix linting errors
13+
- Always use `--write` when running biome check to fix issues in one command
1314
- **Test**: `vitest run --typecheck` or for single test: `vitest run path/to/test.spec.ts`
15+
- **Dev**: Running example projects with `nx` (e.g., `nx run docs:dev`) will automatically rebuild
16+
dependent packages as needed. Don't manually run `pnpm -F zudoku build` repeatedly.
17+
- **Debugging**: During active debugging, leave console.log statements in place and don't fix linter
18+
issues until debugging is complete. Remove console.logs only after feature is confirmed working.
1419

1520
## Architecture
1621

@@ -30,13 +35,14 @@
3035
`import { type ReactNode, useState } from "react"`
3136
- **Errors**: Throw and/or extend `ZudokuError` for custom errors
3237
- **Typescript**: Prefer types over interfaces, PascalCase for components/classes, no `I` prefix for
33-
interfaces
38+
interfaces, avoid type casting (`as`) when possible and use existing types from packages
3439
- **Components**: Use anonymous functions to define components
3540
- **State**: Zustand for global state, React Query for server state
3641
- **Files**: TypeScript strict mode, no console/debugger in production, prefer `const` over `let`,
3742
don't remove `console.log` when debugging
3843
- **Functional**: Prefer immutable functional style, using functions like `Object.fromEntries`,
3944
`map` and `flatMap` to construct new data
45+
- **Control flow**: Prefer early returns over nested if statements, early continue/break in loops
4046

4147
## UI
4248

@@ -47,3 +53,9 @@
4753

4854
- Plugins live in packages/zudoku/lib/plugins/
4955
- Plugins can use things from core, but core should not directly reference plugins
56+
57+
## Examples
58+
59+
- `examples/cosmo-cargo/` - Feature-rich demo of a futuristic space shipping company. Use this to
60+
test new features. Content should match the space/sci-fi tone (quantum, interstellar, warp drives,
61+
etc.). Run with `nx run cosmo-cargo:dev`

docs/pages/docs/components/badge.mdx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ title: Badge
33
sidebar_icon: tag
44
---
55

6-
import { Badge } from "zudoku/ui/Badge";
7-
86
A small badge component used to display status information or labels.
97

108
## Import
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
sidebar_icon: zap
3+
---
4+
5+
# Quantum <span className="text-amber-600 dark:text-amber-400">Express</span>
6+
7+
Introducing Cosmo Cargo's fastest shipping tier. Quantum Express leverages entanglement-based
8+
logistics to deliver your cargo across the galaxy in record time.
9+
10+
## How It Works <Badge variant="secondary">New</Badge>
11+
12+
Quantum Express uses paired particle arrays to instantaneously transfer shipment manifests and
13+
tracking data. While physical cargo still travels via conventional warp drives, our quantum-linked
14+
network ensures:
15+
16+
- **Instant confirmation** of delivery across any distance
17+
- **Real-time cargo monitoring** without light-speed delay
18+
- **Predictive routing** using quantum probability calculations
19+
20+
## Service Tiers
21+
22+
| Tier | Max Distance | Transit Time | Quantum Link |
23+
| ---------- | -------------- | ------------ | ------------ |
24+
| QE-Local | 1 AU | 2-4 hours | Basic |
25+
| QE-System | 50 AU | 12-24 hours | Enhanced |
26+
| QE-Stellar | 10 light-years | 3-7 days | Premium |
27+
28+
## Requirements
29+
30+
To use Quantum Express, your shipments must meet these criteria:
31+
32+
1. **Mass limit**: Under 500kg per package
33+
2. **No quantum-sensitive materials**: Items that could interfere with entanglement arrays
34+
3. **Registered quantum receiver**: Destination must have compatible receiving equipment
35+
4. **Insurance**: Mandatory quantum transit insurance
36+
37+
<Callout type="caution">
38+
Quantum Express is currently in beta. Service availability may vary by sector. Contact your
39+
regional hub for coverage details.
40+
</Callout>
41+
42+
## API Integration
43+
44+
Enable Quantum Express by setting the `service_tier` parameter:
45+
46+
```json
47+
{
48+
"shipment": {
49+
"service_tier": "quantum_express",
50+
"quantum_priority": "high",
51+
"entanglement_pair_id": "QP-7X9-ALPHA"
52+
}
53+
}
54+
```
55+
56+
## Pricing
57+
58+
Quantum Express pricing is calculated based on distance, mass, and current entanglement array
59+
availability. Use our [rate calculator](/api-shipments) to get real-time quotes.

examples/cosmo-cargo/zudoku.config.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,15 +127,15 @@ const config: ZudokuConfig = {
127127
label: "Documentation",
128128
icon: "book-open",
129129
items: [
130-
"documentation",
131130
{ type: "filter", placeholder: "Filter documentation" },
131+
"documentation",
132132
{ type: "section", label: "Operations" },
133133
{
134134
type: "category",
135135
icon: "telescope",
136136
collapsed: false,
137137
label: "Space Operations",
138-
items: ["shipping-process", "tracking"],
138+
items: ["shipping-process", "tracking", "quantum-express"],
139139
},
140140
"global",
141141
{ type: "separator" },

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@
4545
"preact": ">=10.27.3"
4646
},
4747
"patchedDependencies": {
48-
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
4948
"decode-named-character-reference@1.2.0": "patches/decode-named-character-reference@1.2.0.patch"
5049
},
5150
"onlyBuiltDependencies": [

packages/zudoku/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@
259259
"cmdk": "1.1.1",
260260
"dotenv": "^17.2.3",
261261
"embla-carousel-react": "8.6.0",
262+
"estree-util-is-identifier-name": "3.0.0",
262263
"estree-util-value-to-estree": "3.4.1",
263264
"express": "5.2.1",
264265
"fast-equals": "5.2.2",
@@ -268,13 +269,18 @@
268269
"graphql-type-json": "0.3.2",
269270
"graphql-yoga": "5.16.0",
270271
"gray-matter": "4.0.3",
272+
"hast-util-heading-rank": "3.0.0",
271273
"hast-util-to-jsx-runtime": "^2.3.6",
272274
"hast-util-to-string": "3.0.1",
273275
"http-terminator": "3.2.0",
274276
"javascript-stringify": "2.1.0",
275277
"json-schema-to-typescript-lite": "15.0.0",
276278
"loglevel": "1.9.2",
277279
"lucide-react": "0.548.0",
280+
"mdast-util-from-markdown": "2.0.2",
281+
"mdast-util-mdx": "3.0.0",
282+
"mdast-util-mdx-jsx": "3.2.0",
283+
"micromark-extension-mdxjs": "3.0.0",
278284
"motion": "12.23.24",
279285
"nanoevents": "^9.1.0",
280286
"next-themes": "0.4.6",
@@ -338,7 +344,6 @@
338344
"@vitest/coverage-v8": "4.0.16",
339345
"esbuild": "0.27.0",
340346
"happy-dom": "20.0.10",
341-
"mdast-util-mdx": "3.0.0",
342347
"react": "catalog:",
343348
"react-dom": "catalog:",
344349
"rollup-plugin-visualizer": "6.0.5",

packages/zudoku/src/config/validators/NavigationSchema.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import path from "node:path";
22
import { glob } from "glob";
3+
import type { RootContent } from "hast";
34
import type { LucideIcon } from "lucide-react";
5+
import type { Heading, PhrasingContent, Text } from "mdast";
6+
import { fromMarkdown } from "mdast-util-from-markdown";
7+
import { mdxFromMarkdown } from "mdast-util-mdx";
8+
import type { MdxJsxTextElement } from "mdast-util-mdx-jsx";
9+
import { mdxjs } from "micromark-extension-mdxjs";
410
import { readFrontmatter } from "../../lib/util/readFrontmatter.js";
511
import type { ConfigWithMeta } from "../loader.js";
612
import type {
@@ -29,7 +35,12 @@ type FinalNavigationCategoryLinkDoc = Extract<
2935

3036
export type NavigationDoc = ReplaceFields<
3137
FinalNavigationDoc,
32-
{ label: string; categoryLabel?: string; path: string } & ResolvedIcon
38+
{
39+
label: string;
40+
categoryLabel?: string;
41+
path: string;
42+
rich?: RootContent[];
43+
} & ResolvedIcon
3344
>;
3445

3546
export type NavigationLink = ReplaceFields<InputNavigationLink, ResolvedIcon>;
@@ -68,6 +79,49 @@ export type Navigation = NavigationItem[];
6879
const extractTitleFromContent = (content: string): string | undefined =>
6980
content.match(/^\s*#\s(.*)$/m)?.at(1);
7081

82+
// MDX extends PhrasingContent with JSX elements
83+
type MdxPhrasingContent = PhrasingContent | MdxJsxTextElement;
84+
85+
const isMdxJsxElement = (node: MdxPhrasingContent): node is MdxJsxTextElement =>
86+
node.type === "mdxJsxTextElement";
87+
88+
const isTextNode = (node: MdxPhrasingContent): node is Text =>
89+
node.type === "text";
90+
91+
// Extract rich H1 heading content from MDX. Returns AST nodes only when H1 contains JSX elements.
92+
const extractRichH1 = (content: string) => {
93+
try {
94+
const mdast = fromMarkdown(content, {
95+
extensions: [mdxjs()],
96+
mdastExtensions: [mdxFromMarkdown()],
97+
// biome-ignore lint/suspicious/noExplicitAny: mdast-util-from-markdown has type incompatibilities between versions
98+
} as any);
99+
100+
const h1 = mdast.children.find(
101+
(node): node is Heading => node.type === "heading" && node.depth === 1,
102+
);
103+
104+
if (!h1) return undefined;
105+
106+
const hasJsx = h1.children.some(isMdxJsxElement);
107+
108+
// Extract plain text label
109+
const plainLabel = h1.children
110+
.filter(isTextNode)
111+
.map((node) => node.value)
112+
.join("")
113+
.trim();
114+
115+
// MDAST text nodes and MDX JSX nodes are structurally compatible with HAST
116+
// RichText handles both via toJsxRuntime and custom mdxJsx handling
117+
return hasJsx
118+
? { label: plainLabel, rich: h1.children as RootContent[] }
119+
: { label: plainLabel };
120+
} catch {
121+
return undefined;
122+
}
123+
};
124+
71125
const isNavigationItem = (item: unknown): item is NavigationItem =>
72126
item !== undefined;
73127

@@ -119,10 +173,13 @@ export class NavigationResolver {
119173

120174
const { data, content } = await readFrontmatter(foundMatches);
121175

176+
const richH1 = extractRichH1(content);
177+
122178
const label =
123179
data.navigation_label ??
124180
data.sidebar_label ??
125181
data.title ??
182+
richH1?.label ??
126183
extractTitleFromContent(content) ??
127184
filePath;
128185

@@ -136,6 +193,7 @@ export class NavigationResolver {
136193
display: data.navigation_display,
137194
categoryLabel,
138195
path: fileNoExt,
196+
rich: richH1?.rich,
139197
} satisfies NavigationDoc;
140198

141199
return doc;

packages/zudoku/src/lib/components/TopNavigation.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ const getPathForItem = (item: NavigationItem): string => {
6161
if (
6262
child.type !== "category" &&
6363
child.type !== "separator" &&
64-
child.type !== "section"
64+
child.type !== "section" &&
65+
child.type !== "filter"
6566
) {
6667
return getPathForItem(child);
6768
}

packages/zudoku/src/lib/components/navigation/NavigationItem.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "zudoku/ui/Tooltip.js";
66
import type { NavigationItem as NavigationItemType } from "../../../config/validators/NavigationSchema.js";
77
import { useAuth } from "../../authentication/hook.js";
88
import { cn } from "../../util/cn.js";
9+
import { RichText } from "../../util/hastToJsx.js";
910
import { joinUrl } from "../../util/joinUrl.js";
1011
import { AnchorLink } from "../AnchorLink.js";
1112
import { useViewportAnchor } from "../context/ViewportAnchorContext.js";
@@ -112,6 +113,10 @@ export const NavigationItem = ({
112113
)}
113114
<NavigationBadge {...item.badge} />
114115
</>
116+
) : item.rich ? (
117+
<span>
118+
<RichText>{item.rich}</RichText>
119+
</span>
115120
) : (
116121
item.label
117122
)}

packages/zudoku/src/lib/components/navigation/Toc.tsx

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { TocEntry } from "@stefanprobst/rehype-extract-toc";
21
import { ListTreeIcon } from "lucide-react";
32
import {
43
type CSSProperties,
@@ -7,7 +6,9 @@ import {
76
useRef,
87
useState,
98
} from "react";
9+
import type { TocEntry } from "../../../vite/mdx/rehype-extract-toc-with-jsx.js";
1010
import { cn } from "../../util/cn.js";
11+
import { RichText } from "../../util/hastToJsx.js";
1112
import { AnchorLink } from "../AnchorLink.js";
1213
import { useViewportAnchor } from "../context/ViewportAnchorContext.js";
1314

@@ -22,24 +23,22 @@ const TocItem = ({
2223
item: TocEntry;
2324
isActive: boolean;
2425
className?: string;
25-
}>) => {
26-
return (
27-
<li className={cn("truncate", className)} title={item.value}>
28-
<AnchorLink
29-
to={`#${item.id}`}
30-
{...{ [DATA_ANCHOR_ATTR]: item.id }}
31-
className={cn(
32-
isActive
33-
? "text-primary"
34-
: "hover:text-accent-foreground text-muted-foreground",
35-
)}
36-
>
37-
{item.value}
38-
</AnchorLink>
39-
{children}
40-
</li>
41-
);
42-
};
26+
}>) => (
27+
<li className={cn("truncate", className)} title={item.text}>
28+
<AnchorLink
29+
to={`#${item.id}`}
30+
{...{ [DATA_ANCHOR_ATTR]: item.id }}
31+
className={cn(
32+
isActive
33+
? "text-primary"
34+
: "hover:text-accent-foreground text-muted-foreground",
35+
)}
36+
>
37+
{item.rich ? <RichText>{item.rich}</RichText> : item.text}
38+
</AnchorLink>
39+
{children}
40+
</li>
41+
);
4342

4443
export const Toc = ({ entries }: { entries: TocEntry[] }) => {
4544
const { activeAnchor } = useViewportAnchor();

0 commit comments

Comments
 (0)