zudoku 0.86.0 → 0.88.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 +170 -2
- 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/app/entry.server.tsx +6 -0
- package/src/lib/ui/Stepper.tsx +39 -3
- package/src/vite/config.ts +30 -1
- 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 = {
|
|
@@ -8249,6 +8404,19 @@ var getAppClientEntryPath = () => path18.posix.join(getZudokuRootDir(), "src/app
|
|
|
8249
8404
|
var getAppServerEntryPath = () => path18.posix.join(getZudokuRootDir(), "src/app/entry.server.tsx");
|
|
8250
8405
|
var hasLoggedCdnInfo = false;
|
|
8251
8406
|
var MEDIA_REGEX = /\.(a?png|jpe?g|gif|bmp|svg|webp|tiff|ico|webm|ogg|mp3|wav|m4a|avif|mp4)/i;
|
|
8407
|
+
var getDepScanEntries = () => {
|
|
8408
|
+
const src = path18.posix.join(getZudokuRootDir(), "src");
|
|
8409
|
+
return [
|
|
8410
|
+
`${src}/{app,lib}/**/*.{ts,tsx}`,
|
|
8411
|
+
// Client-environment scan: `entry.server.tsx` imports
|
|
8412
|
+
// `virtual:zudoku-markdown-files`, which only the `ssr` environment
|
|
8413
|
+
// provides. Including it fails the scan and disables pre-bundling entirely.
|
|
8414
|
+
`!${getAppServerEntryPath()}`,
|
|
8415
|
+
// Not published, but present in the monorepo — keeps local dev scanning the
|
|
8416
|
+
// same file set an installed site does (and out of vitest's dependencies).
|
|
8417
|
+
`!${src}/**/*.test.*`
|
|
8418
|
+
];
|
|
8419
|
+
};
|
|
8252
8420
|
var defineEnvVars = (vars) => Object.fromEntries(
|
|
8253
8421
|
vars.flatMap((v) => [
|
|
8254
8422
|
[`process.env.${v}`, JSON.stringify(process.env[v])],
|
|
@@ -8396,7 +8564,7 @@ async function getViteConfig(dir, configEnv, options = {}) {
|
|
|
8396
8564
|
}
|
|
8397
8565
|
},
|
|
8398
8566
|
optimizeDeps: {
|
|
8399
|
-
entries:
|
|
8567
|
+
entries: getDepScanEntries(),
|
|
8400
8568
|
exclude: ["zudoku"],
|
|
8401
8569
|
include: [
|
|
8402
8570
|
"@mdx-js/react",
|
|
@@ -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/app/entry.server.tsx
CHANGED
|
@@ -186,6 +186,12 @@ export const handleRequest = async ({
|
|
|
186
186
|
|
|
187
187
|
try {
|
|
188
188
|
const reactStream = await renderToReadableStream(App, {
|
|
189
|
+
// The stream is only read after `allReady`, so nothing is streamed
|
|
190
|
+
// progressively anyway. Without this, React outlines completed Suspense
|
|
191
|
+
// boundaries larger than the default chunk size (12.8 kB) into a hidden
|
|
192
|
+
// segment swapped in by an inline script, leaving only the fallback
|
|
193
|
+
// visible to clients that don't run JS (AI crawlers, curl, reader mode).
|
|
194
|
+
progressiveChunkSize: Number.MAX_SAFE_INTEGER,
|
|
189
195
|
onError(error) {
|
|
190
196
|
status = 500;
|
|
191
197
|
logger.error(`SSR Error (${request.method} ${request.url}):`, error);
|
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 };
|
package/src/vite/config.ts
CHANGED
|
@@ -40,6 +40,35 @@ let hasLoggedCdnInfo = false;
|
|
|
40
40
|
const MEDIA_REGEX =
|
|
41
41
|
/\.(a?png|jpe?g|gif|bmp|svg|webp|tiff|ico|webm|ogg|mp3|wav|m4a|avif|mp4)/i;
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Seeds Vite's dependency scanner with Zudoku's own source.
|
|
45
|
+
*
|
|
46
|
+
* `zudoku` is in `optimizeDeps.exclude`, so its source is served to the browser
|
|
47
|
+
* unbundled and its bare imports have to be pre-bundled by name. When Zudoku is
|
|
48
|
+
* installed normally its source lives inside `node_modules`, and Vite skips
|
|
49
|
+
* runtime dependency discovery for any importer under `node_modules` (it serves
|
|
50
|
+
* the raw file with a `?v=` query instead). This scan is therefore the only
|
|
51
|
+
* chance to find those dependencies — anything it misses reaches the browser as
|
|
52
|
+
* raw CommonJS and fails to parse as ESM.
|
|
53
|
+
*
|
|
54
|
+
* The scan aborts wholesale on a single unresolvable import, so the entries must
|
|
55
|
+
* not include modules that only resolve in another environment.
|
|
56
|
+
*/
|
|
57
|
+
export const getDepScanEntries = () => {
|
|
58
|
+
const src = path.posix.join(getZudokuRootDir(), "src");
|
|
59
|
+
|
|
60
|
+
return [
|
|
61
|
+
`${src}/{app,lib}/**/*.{ts,tsx}`,
|
|
62
|
+
// Client-environment scan: `entry.server.tsx` imports
|
|
63
|
+
// `virtual:zudoku-markdown-files`, which only the `ssr` environment
|
|
64
|
+
// provides. Including it fails the scan and disables pre-bundling entirely.
|
|
65
|
+
`!${getAppServerEntryPath()}`,
|
|
66
|
+
// Not published, but present in the monorepo — keeps local dev scanning the
|
|
67
|
+
// same file set an installed site does (and out of vitest's dependencies).
|
|
68
|
+
`!${src}/**/*.test.*`,
|
|
69
|
+
];
|
|
70
|
+
};
|
|
71
|
+
|
|
43
72
|
const defineEnvVars = (vars: string[]) =>
|
|
44
73
|
Object.fromEntries(
|
|
45
74
|
vars.flatMap((v) => [
|
|
@@ -229,7 +258,7 @@ export async function getViteConfig(
|
|
|
229
258
|
},
|
|
230
259
|
},
|
|
231
260
|
optimizeDeps: {
|
|
232
|
-
entries:
|
|
261
|
+
entries: getDepScanEntries(),
|
|
233
262
|
exclude: ["zudoku"],
|
|
234
263
|
include: [
|
|
235
264
|
"@mdx-js/react",
|
|
@@ -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");
|