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 +156 -1
- package/dist/declarations/lib/ui/Stepper.d.ts +5 -2
- package/dist/declarations/lib/util/MdxComponents.d.ts +1 -1
- package/dist/declarations/vite/mdx/rehype-extract-toc-with-jsx.d.ts +2 -2
- package/docs/components/stepper.mdx +34 -1
- package/docs/configuration/api-reference.md +1 -1
- package/docs/configuration/llms.md +4 -0
- package/package.json +1 -1
- package/src/lib/ui/Stepper.tsx +39 -3
- package/src/vite/mdx/rehype-extract-toc-with-jsx.ts +282 -3
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)
|
|
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
|
|
2
|
-
|
|
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("
|
|
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](
|
|
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
package/src/lib/ui/Stepper.tsx
CHANGED
|
@@ -1,8 +1,44 @@
|
|
|
1
|
-
import type
|
|
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 }:
|
|
5
|
-
|
|
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
|
-
|
|
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)
|
|
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");
|