zudoku 0.86.0 → 0.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/cli.js CHANGED
@@ -7552,11 +7552,166 @@ var rehype_extract_toc_with_jsx_export_default = rehypeExtractTocWithJsxExport;
7552
7552
  import { headingRank } from "hast-util-heading-rank";
7553
7553
  import { toString as hastToString } from "hast-util-to-string";
7554
7554
  import { visit as visit2 } from "unist-util-visit";
7555
+ var BLOCK_TAGS = /* @__PURE__ */ new Set([
7556
+ "blockquote",
7557
+ "details",
7558
+ "div",
7559
+ "figure",
7560
+ "h1",
7561
+ "h2",
7562
+ "h3",
7563
+ "h4",
7564
+ "h5",
7565
+ "h6",
7566
+ "hr",
7567
+ "iframe",
7568
+ "img",
7569
+ "ol",
7570
+ "p",
7571
+ "pre",
7572
+ "section",
7573
+ "table",
7574
+ "ul",
7575
+ "video"
7576
+ ]);
7577
+ var EMPHASIS_TAGS = /* @__PURE__ */ new Set(["b", "em", "i", "strong"]);
7578
+ var MAX_RENDERED_DEPTH = 3;
7579
+ var TOC_ANCHOR_PROPERTY = "dataTocAnchor";
7580
+ var isTocEnabled = (node) => {
7581
+ const attribute = node.attributes.find(
7582
+ (attr2) => attr2.type === "mdxJsxAttribute" && attr2.name === "toc"
7583
+ );
7584
+ if (attribute?.type !== "mdxJsxAttribute") return true;
7585
+ if (attribute.value == null) return true;
7586
+ const value = typeof attribute.value === "string" ? attribute.value : attribute.value.value;
7587
+ return value.trim() !== "false";
7588
+ };
7589
+ var unwrapEmphasis = (nodes) => {
7590
+ let current = nodes;
7591
+ while (current.length === 1) {
7592
+ const [only] = current;
7593
+ if (only?.type !== "element" || !EMPHASIS_TAGS.has(only.tagName)) break;
7594
+ current = only.children;
7595
+ }
7596
+ return current;
7597
+ };
7598
+ var getStepTitle = (item) => {
7599
+ const children = item.children.filter(
7600
+ (child) => !(child.type === "text" && child.value.trim() === "")
7601
+ );
7602
+ const [first] = children;
7603
+ if (!first) return;
7604
+ if (first.type === "element" && first.tagName === "p") {
7605
+ return { owner: first, nodes: unwrapEmphasis(first.children) };
7606
+ }
7607
+ const bodyStart = children.findIndex(
7608
+ (child) => child.type === "element" && BLOCK_TAGS.has(child.tagName)
7609
+ );
7610
+ const nodes = bodyStart === -1 ? children : children.slice(0, bodyStart);
7611
+ return nodes.length > 0 ? { owner: item, nodes: unwrapEmphasis(nodes) } : void 0;
7612
+ };
7613
+ var getFrontmatterTitle = (tree) => {
7614
+ let title;
7615
+ visit2(tree, "mdxjsEsm", (node) => {
7616
+ for (const statement of node.data?.estree?.body ?? []) {
7617
+ if (statement.type !== "ExportNamedDeclaration" || statement.declaration?.type !== "VariableDeclaration") {
7618
+ continue;
7619
+ }
7620
+ for (const declarator of statement.declaration.declarations) {
7621
+ if (declarator.id.type !== "Identifier" || declarator.id.name !== "frontmatter" || declarator.init?.type !== "ObjectExpression") {
7622
+ continue;
7623
+ }
7624
+ for (const property of declarator.init.properties) {
7625
+ if (property.type !== "Property") continue;
7626
+ const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : void 0;
7627
+ if (key === "title" && property.value.type === "Literal" && typeof property.value.value === "string") {
7628
+ title = property.value.value;
7629
+ }
7630
+ }
7631
+ }
7632
+ }
7633
+ });
7634
+ return title;
7635
+ };
7636
+ var createIdFactory = (tree) => {
7637
+ const used = /* @__PURE__ */ new Set();
7638
+ const frontmatterTitle = getFrontmatterTitle(tree);
7639
+ if (frontmatterTitle) used.add(slugify(frontmatterTitle));
7640
+ visit2(tree, "element", (node) => {
7641
+ if (node.properties?.id) used.add(String(node.properties.id));
7642
+ });
7643
+ return (text) => {
7644
+ const base = slugify(text) || "step";
7645
+ let id = base;
7646
+ for (let i = 2; used.has(id); i++) id = `${base}-${i}`;
7647
+ used.add(id);
7648
+ return id;
7649
+ };
7650
+ };
7651
+ var collectSteps = (tree) => {
7652
+ const steppers = [];
7653
+ visit2(tree, "mdxJsxFlowElement", (node) => {
7654
+ if (node.name === "Stepper" && isTocEnabled(node)) steppers.push(node);
7655
+ });
7656
+ const steps = /* @__PURE__ */ new Map();
7657
+ const lists = /* @__PURE__ */ new Map();
7658
+ if (steppers.length === 0) return { steps, lists };
7659
+ const nextId = createIdFactory(tree);
7660
+ for (const stepper of steppers) {
7661
+ const items = stepper.children.flatMap((list) => {
7662
+ if (list.type !== "element" || list.tagName !== "ol") return [];
7663
+ lists.set(list, stepper);
7664
+ return list.children;
7665
+ });
7666
+ for (const item of items) {
7667
+ if (item.type !== "element" || item.tagName !== "li") continue;
7668
+ const title = getStepTitle(item);
7669
+ if (!title) continue;
7670
+ const text = title.nodes.map((node) => hastToString(node)).join("").trim();
7671
+ if (!text) continue;
7672
+ title.owner.properties ??= {};
7673
+ title.owner.properties.id ??= nextId(text);
7674
+ steps.set(title.owner, {
7675
+ stepper,
7676
+ text,
7677
+ id: String(title.owner.properties.id),
7678
+ ...title.nodes.some((node) => node.type !== "text") ? { rich: title.nodes } : {}
7679
+ });
7680
+ }
7681
+ }
7682
+ return { steps, lists };
7683
+ };
7555
7684
  var rehypeExtractTocWithJsx = () => (tree, vfile) => {
7685
+ const { steps, lists } = collectSteps(tree);
7556
7686
  const headings = [];
7687
+ let headingDepth = 1;
7688
+ const stepperDepths = /* @__PURE__ */ new Map();
7557
7689
  visit2(tree, "element", (node) => {
7558
7690
  const level = headingRank(node);
7559
- if (!level) return;
7691
+ if (!level) {
7692
+ const stepper = lists.get(node);
7693
+ if (stepper) {
7694
+ if (!stepperDepths.has(stepper)) {
7695
+ stepperDepths.set(stepper, headingDepth + 1);
7696
+ }
7697
+ return;
7698
+ }
7699
+ const step = steps.get(node);
7700
+ if (!step) return;
7701
+ const depth = stepperDepths.get(step.stepper) ?? headingDepth + 1;
7702
+ if (depth <= MAX_RENDERED_DEPTH) {
7703
+ node.properties ??= {};
7704
+ node.properties[TOC_ANCHOR_PROPERTY] = "";
7705
+ }
7706
+ headings.push({
7707
+ depth,
7708
+ text: step.text,
7709
+ id: step.id,
7710
+ ...step.rich ? { rich: step.rich } : {}
7711
+ });
7712
+ return;
7713
+ }
7714
+ headingDepth = level;
7560
7715
  const richChildren = node.children;
7561
7716
  const hasRichContent = richChildren.some((child) => child.type !== "text");
7562
7717
  const heading = {
@@ -1,3 +1,6 @@
1
- import type { PropsWithChildren } from "react";
2
- declare const Stepper: ({ children }: PropsWithChildren) => import("react").JSX.Element;
1
+ import { type PropsWithChildren } from "react";
2
+ export type StepperProps = PropsWithChildren<{
3
+ toc?: boolean;
4
+ }>;
5
+ declare const Stepper: ({ children }: StepperProps) => import("react").JSX.Element;
3
6
  export { Stepper };
@@ -28,7 +28,7 @@ export declare const MdxComponents: {
28
28
  className?: string;
29
29
  icon?: boolean | import("react").ReactNode;
30
30
  }) => import("react").JSX.Element;
31
- Stepper: ({ children }: import("react").PropsWithChildren) => import("react").JSX.Element;
31
+ Stepper: ({ children }: import("../ui/Stepper.js").StepperProps) => import("react").JSX.Element;
32
32
  Mermaid: (props: JSX.IntrinsicElements) => import("react").JSX.Element;
33
33
  SyntaxHighlight: (props: JSX.IntrinsicElements) => import("react").JSX.Element;
34
34
  tip: (props: JSX.IntrinsicElements) => import("react").JSX.Element;
@@ -1,4 +1,4 @@
1
- import type { RootContent } from "hast";
1
+ import type { Root, RootContent } from "hast";
2
2
  import type { Plugin } from "unified";
3
3
  export type TocEntry = {
4
4
  depth: number;
@@ -13,5 +13,5 @@ declare module "vfile" {
13
13
  toc: Toc;
14
14
  }
15
15
  }
16
- declare const rehypeExtractTocWithJsx: Plugin<[]>;
16
+ declare const rehypeExtractTocWithJsx: Plugin<[], Root>;
17
17
  export default rehypeExtractTocWithJsx;
@@ -41,7 +41,7 @@ Wrap Markdown lists with the `Stepper` component to create a vertical stepper li
41
41
  </div>
42
42
  <div className="flex-1">
43
43
 
44
- <Stepper>
44
+ <Stepper toc={false}>
45
45
 
46
46
  1. **Identify the Problem**
47
47
  1. **Plan Your Project**
@@ -52,6 +52,39 @@ Wrap Markdown lists with the `Stepper` component to create a vertical stepper li
52
52
  </div>
53
53
  </div>
54
54
 
55
+ ## Table of Contents
56
+
57
+ Steps are anchorable and show up in the "On this page" sidebar, nested one level below the heading
58
+ they follow:
59
+
60
+ | Stepper placed after… | Steps appear as… |
61
+ | ------------------------------------- | ----------------------------------------------- |
62
+ | The page title (or no heading at all) | Top-level entries |
63
+ | An `h2` | Sub-entries of that `h2` |
64
+ | An `h3` or deeper | Nothing — the same cut-off an `h4` heading hits |
65
+
66
+ That means a page that is nothing but a stepper (a tutorial, for example) gets a usable table of
67
+ contents automatically, while a stepper buried inside an existing section doesn't flood it.
68
+
69
+ Each step's title — the first line of the list item — becomes the entry. A step whose first block
70
+ isn't text (a code block, for instance) is skipped.
71
+
72
+ ### Opting out
73
+
74
+ Set `toc={false}` for a stepper that is a quick checklist rather than a section of the page worth
75
+ linking to:
76
+
77
+ <!-- prettier-ignore -->
78
+ ```tsx
79
+ <Stepper toc={false}>
80
+
81
+ 1. **Find the reason code**
82
+ 1. **Look up the code**
83
+ 1. **Re-file**
84
+
85
+ </Stepper>
86
+ ```
87
+
55
88
  ## Advanced Example
56
89
 
57
90
  <Stepper>
@@ -276,7 +276,7 @@ Available options:
276
276
  schemes section on the info page, and the Authorize dialog in the playground). Disabled by default
277
277
  (`true`). Set to `false` to enable security scheme support
278
278
  - `disableMcpAuthInstructions`: Hide the authentication instructions on
279
- [MCP server](../guides/mcp-servers.md) endpoints. The MCP card normally derives a credential
279
+ [MCP server](/docs/guides/mcp-servers) endpoints. The MCP card normally derives a credential
280
280
  header from the operation's security scheme and shows it in every install snippet. Set to `true`
281
281
  to render the server as unauthenticated instead — no header snippets and no "replace
282
282
  `YOUR_API_KEY`" steps
@@ -113,10 +113,14 @@ dist/
113
113
  └── ...
114
114
  ```
115
115
 
116
+ ::if{mode=opensource}
117
+
116
118
  On Vercel builds, this tree is written beneath `.vercel/output/static/` instead of `dist/`. The
117
119
  deployed public URLs remain the same. See the [Vercel deployment guide](/docs/deploy/vercel) for the
118
120
  generated routing and Markdown content-negotiation behavior.
119
121
 
122
+ ::
123
+
120
124
  **Important:** Individual `.md` files are only kept in the final build if `publishMarkdown: true`.
121
125
  If only `llmsTxt` or `llmsTxtFull` is enabled, the `.md` files are generated temporarily during the
122
126
  build but deleted after the `llms.txt` files are created.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zudoku",
3
- "version": "0.86.0",
3
+ "version": "0.87.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.22.0"
@@ -1,8 +1,44 @@
1
- import type { PropsWithChildren } from "react";
1
+ import { type PropsWithChildren, useEffect, useRef } from "react";
2
+ import { useViewportAnchor } from "../components/context/ViewportAnchorContext.js";
3
+
4
+ export type StepperProps = PropsWithChildren<{
5
+ /**
6
+ * Whether the steps appear in the table of contents. Defaults to `true`.
7
+ *
8
+ * Read at build time by `rehype-extract-toc-with-jsx`, which also assigns the
9
+ * step anchors — it's declared here so MDX authors get the prop typed.
10
+ */
11
+ toc?: boolean;
12
+ }>;
2
13
 
3
14
  // "stepper" class is defined in main.css
4
- const Stepper = ({ children }: PropsWithChildren) => {
5
- return <div className="stepper">{children}</div>;
15
+ const Stepper = ({ children }: StepperProps) => {
16
+ const ref = useRef<HTMLDivElement>(null);
17
+ const { observe, unobserve } = useViewportAnchor();
18
+
19
+ useEffect(() => {
20
+ // `rehype-extract-toc-with-jsx` marks the step anchors the toc renders —
21
+ // on the title paragraph of a loose step, or on the `<li>` of a tight one.
22
+ // Steps too deep to appear in the toc keep their id but aren't marked, so
23
+ // scrolling through them can't blank out the enclosing heading.
24
+ const anchors = ref.current?.querySelectorAll<HTMLElement>(
25
+ ":scope > ol > li[data-toc-anchor], :scope > ol > li > p[data-toc-anchor]",
26
+ );
27
+
28
+ if (!anchors?.length) return;
29
+
30
+ for (const anchor of anchors) observe(anchor);
31
+
32
+ return () => {
33
+ for (const anchor of anchors) unobserve(anchor);
34
+ };
35
+ }, [observe, unobserve]);
36
+
37
+ return (
38
+ <div className="stepper" ref={ref}>
39
+ {children}
40
+ </div>
41
+ );
6
42
  };
7
43
 
8
44
  export { Stepper };
@@ -1,8 +1,11 @@
1
- import type { Element, RootContent } from "hast";
1
+ import type { Element, Root, RootContent } from "hast";
2
2
  import { headingRank } from "hast-util-heading-rank";
3
3
  import { toString as hastToString } from "hast-util-to-string";
4
+ import type { MdxjsEsm } from "mdast-util-mdx";
5
+ import type { MdxJsxFlowElementHast } from "mdast-util-mdx-jsx";
4
6
  import type { Plugin } from "unified";
5
7
  import { visit } from "unist-util-visit";
8
+ import { slugify } from "../../lib/util/slugify.js";
6
9
 
7
10
  export type TocEntry = {
8
11
  depth: number;
@@ -21,13 +24,289 @@ declare module "vfile" {
21
24
  }
22
25
  }
23
26
 
24
- const rehypeExtractTocWithJsx: Plugin<[]> = () => (tree, vfile) => {
27
+ /** Tags that end a step's title and start its body. */
28
+ const BLOCK_TAGS = new Set([
29
+ "blockquote",
30
+ "details",
31
+ "div",
32
+ "figure",
33
+ "h1",
34
+ "h2",
35
+ "h3",
36
+ "h4",
37
+ "h5",
38
+ "h6",
39
+ "hr",
40
+ "iframe",
41
+ "img",
42
+ "ol",
43
+ "p",
44
+ "pre",
45
+ "section",
46
+ "table",
47
+ "ul",
48
+ "video",
49
+ ]);
50
+
51
+ /** Inline wrappers that carry no meaning once a step title is in the toc. */
52
+ const EMPHASIS_TAGS = new Set(["b", "em", "i", "strong"]);
53
+
54
+ /**
55
+ * `TocContent` renders two levels, so entries deeper than this are extracted
56
+ * but never shown — the cut-off `h4` headings already hit.
57
+ */
58
+ const MAX_RENDERED_DEPTH = 3;
59
+
60
+ /**
61
+ * Marks the step anchors the toc actually renders, so `Stepper` observes only
62
+ * those. Deeper steps keep their id and stay linkable, but tracking them would
63
+ * set an `activeAnchor` with no toc entry and blank out the highlight — the
64
+ * same reason `MdxPage` registers only `h2` and `h3` as navigation anchors.
65
+ */
66
+ const TOC_ANCHOR_PROPERTY = "dataTocAnchor";
67
+
68
+ type StepTitle = { owner: Element; nodes: RootContent[] };
69
+
70
+ type Step = {
71
+ /** The `<Stepper>` this step belongs to, so all its steps share a depth. */
72
+ stepper: MdxJsxFlowElementHast;
73
+ text: string;
74
+ id: string;
75
+ rich?: RootContent[];
76
+ };
77
+
78
+ /**
79
+ * Steps opt out with `<Stepper toc={false}>`. Anything else — including a bare
80
+ * `toc` and `toc={true}` — keeps them in the table of contents.
81
+ */
82
+ const isTocEnabled = (node: MdxJsxFlowElementHast) => {
83
+ const attribute = node.attributes.find(
84
+ (attr) => attr.type === "mdxJsxAttribute" && attr.name === "toc",
85
+ );
86
+
87
+ if (attribute?.type !== "mdxJsxAttribute") return true;
88
+ if (attribute.value == null) return true;
89
+
90
+ const value =
91
+ typeof attribute.value === "string"
92
+ ? attribute.value
93
+ : attribute.value.value;
94
+
95
+ return value.trim() !== "false";
96
+ };
97
+
98
+ const unwrapEmphasis = (nodes: RootContent[]): RootContent[] => {
99
+ let current = nodes;
100
+
101
+ while (current.length === 1) {
102
+ const [only] = current;
103
+ if (only?.type !== "element" || !EMPHASIS_TAGS.has(only.tagName)) break;
104
+ current = only.children;
105
+ }
106
+
107
+ return current;
108
+ };
109
+
110
+ /**
111
+ * The leading inline content of a step, plus the element the anchor belongs on.
112
+ *
113
+ * Loose list items wrap their title in a paragraph, so the anchor goes there:
114
+ * a step can be arbitrarily tall, and the viewport observer only reliably
115
+ * activates short elements. Tight list items are inline-only (and therefore
116
+ * short), so they anchor on the `<li>` itself.
117
+ */
118
+ const getStepTitle = (item: Element): StepTitle | undefined => {
119
+ const children = item.children.filter(
120
+ (child) => !(child.type === "text" && child.value.trim() === ""),
121
+ );
122
+ const [first] = children;
123
+ if (!first) return;
124
+
125
+ if (first.type === "element" && first.tagName === "p") {
126
+ return { owner: first, nodes: unwrapEmphasis(first.children) };
127
+ }
128
+
129
+ const bodyStart = children.findIndex(
130
+ (child) => child.type === "element" && BLOCK_TAGS.has(child.tagName),
131
+ );
132
+ const nodes = bodyStart === -1 ? children : children.slice(0, bodyStart);
133
+
134
+ return nodes.length > 0
135
+ ? { owner: item, nodes: unwrapEmphasis(nodes) }
136
+ : undefined;
137
+ };
138
+
139
+ /**
140
+ * The frontmatter `title` never reaches this tree, but `MdxPage` renders it as
141
+ * an `h1` with a slugified id. Without reserving that slug a step sharing the
142
+ * page title produces a duplicate id, and both the anchor link and the viewport
143
+ * observer resolve to the heading instead of the step.
144
+ */
145
+ const getFrontmatterTitle = (tree: Root): string | undefined => {
146
+ let title: string | undefined;
147
+
148
+ visit(tree, "mdxjsEsm", (node: MdxjsEsm) => {
149
+ for (const statement of node.data?.estree?.body ?? []) {
150
+ if (
151
+ statement.type !== "ExportNamedDeclaration" ||
152
+ statement.declaration?.type !== "VariableDeclaration"
153
+ ) {
154
+ continue;
155
+ }
156
+
157
+ for (const declarator of statement.declaration.declarations) {
158
+ if (
159
+ declarator.id.type !== "Identifier" ||
160
+ declarator.id.name !== "frontmatter" ||
161
+ declarator.init?.type !== "ObjectExpression"
162
+ ) {
163
+ continue;
164
+ }
165
+
166
+ for (const property of declarator.init.properties) {
167
+ if (property.type !== "Property") continue;
168
+
169
+ const key =
170
+ property.key.type === "Identifier"
171
+ ? property.key.name
172
+ : property.key.type === "Literal"
173
+ ? String(property.key.value)
174
+ : undefined;
175
+
176
+ if (
177
+ key === "title" &&
178
+ property.value.type === "Literal" &&
179
+ typeof property.value.value === "string"
180
+ ) {
181
+ title = property.value.value;
182
+ }
183
+ }
184
+ }
185
+ }
186
+ });
187
+
188
+ return title;
189
+ };
190
+
191
+ /** Slugs step anchors without colliding with ids already in the document. */
192
+ const createIdFactory = (tree: Root) => {
193
+ const used = new Set<string>();
194
+
195
+ const frontmatterTitle = getFrontmatterTitle(tree);
196
+ if (frontmatterTitle) used.add(slugify(frontmatterTitle));
197
+
198
+ visit(tree, "element", (node: Element) => {
199
+ if (node.properties?.id) used.add(String(node.properties.id));
200
+ });
201
+
202
+ return (text: string) => {
203
+ const base = slugify(text) || "step";
204
+ let id = base;
205
+ for (let i = 2; used.has(id); i++) id = `${base}-${i}`;
206
+ used.add(id);
207
+ return id;
208
+ };
209
+ };
210
+
211
+ /**
212
+ * Assigns anchor ids to `<Stepper>` steps and indexes them by the element that
213
+ * carries the anchor, so the document-order pass below can place them.
214
+ */
215
+ const collectSteps = (tree: Root) => {
216
+ const steppers: MdxJsxFlowElementHast[] = [];
217
+
218
+ visit(tree, "mdxJsxFlowElement", (node: MdxJsxFlowElementHast) => {
219
+ if (node.name === "Stepper" && isTocEnabled(node)) steppers.push(node);
220
+ });
221
+
222
+ const steps = new Map<Element, Step>();
223
+ /** Each stepper's list, so depth can be pinned at the list's own position. */
224
+ const lists = new Map<Element, MdxJsxFlowElementHast>();
225
+ if (steppers.length === 0) return { steps, lists };
226
+
227
+ const nextId = createIdFactory(tree);
228
+
229
+ for (const stepper of steppers) {
230
+ const items = stepper.children.flatMap((list) => {
231
+ if (list.type !== "element" || list.tagName !== "ol") return [];
232
+ lists.set(list, stepper);
233
+ return list.children;
234
+ });
235
+
236
+ for (const item of items) {
237
+ if (item.type !== "element" || item.tagName !== "li") continue;
238
+
239
+ const title = getStepTitle(item);
240
+ if (!title) continue;
241
+
242
+ const text = title.nodes
243
+ .map((node) => hastToString(node))
244
+ .join("")
245
+ .trim();
246
+ if (!text) continue;
247
+
248
+ title.owner.properties ??= {};
249
+ title.owner.properties.id ??= nextId(text);
250
+
251
+ steps.set(title.owner, {
252
+ stepper,
253
+ text,
254
+ id: String(title.owner.properties.id),
255
+ ...(title.nodes.some((node) => node.type !== "text")
256
+ ? { rich: title.nodes }
257
+ : {}),
258
+ });
259
+ }
260
+ }
261
+
262
+ return { steps, lists };
263
+ };
264
+
265
+ const rehypeExtractTocWithJsx: Plugin<[], Root> = () => (tree, vfile) => {
266
+ const { steps, lists } = collectSteps(tree);
25
267
  const headings: TocEntry[] = [];
26
268
 
269
+ // Steps nest one level below the heading they follow, so a stepper under an
270
+ // `h2` renders as sub-items and one under an `h3` ends up too deep to render
271
+ // — the same cut-off an `h4` already hits. Steps before any heading sit at
272
+ // depth 2, alongside the headings under the page title.
273
+ let headingDepth = 1;
274
+ const stepperDepths = new Map<MdxJsxFlowElementHast, number>();
275
+
27
276
  visit(tree, "element", (node: Element) => {
28
277
  const level = headingRank(node);
29
278
 
30
- if (!level) return;
279
+ if (!level) {
280
+ // Pinned at the list itself, so neither a skipped step nor a heading
281
+ // buried in a step body can shift the rest of the stepper deeper.
282
+ const stepper = lists.get(node);
283
+ if (stepper) {
284
+ if (!stepperDepths.has(stepper)) {
285
+ stepperDepths.set(stepper, headingDepth + 1);
286
+ }
287
+ return;
288
+ }
289
+
290
+ const step = steps.get(node);
291
+ if (!step) return;
292
+
293
+ const depth = stepperDepths.get(step.stepper) ?? headingDepth + 1;
294
+
295
+ if (depth <= MAX_RENDERED_DEPTH) {
296
+ node.properties ??= {};
297
+ node.properties[TOC_ANCHOR_PROPERTY] = "";
298
+ }
299
+
300
+ headings.push({
301
+ depth,
302
+ text: step.text,
303
+ id: step.id,
304
+ ...(step.rich ? { rich: step.rich } : {}),
305
+ });
306
+ return;
307
+ }
308
+
309
+ headingDepth = level;
31
310
 
32
311
  const richChildren = node.children as RootContent[];
33
312
  const hasRichContent = richChildren.some((child) => child.type !== "text");