@suzumiyaaoba/mdxr 0.1.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/LICENSE +21 -0
- package/README.md +56 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +2902 -0
- package/dist/components.d.mts +791 -0
- package/dist/components.mjs +2 -0
- package/dist/config-SI9IyFiC.mjs +184 -0
- package/dist/doc-context-CEqzMYKv.d.mts +568 -0
- package/dist/index.d.mts +20 -0
- package/dist/index.mjs +4 -0
- package/dist/ui-GKFD0mx5.mjs +15986 -0
- package/package.json +131 -0
- package/skill/SKILL.md +49 -0
- package/skill/references/components/charts.md +172 -0
- package/skill/references/components/document.md +98 -0
- package/skill/references/components/forms.md +35 -0
- package/skill/references/components/investigation.md +198 -0
- package/skill/references/components/layout.md +68 -0
- package/skill/references/components/output.md +158 -0
- package/skill/references/components/planning.md +155 -0
- package/skill/references/components/reports.md +252 -0
- package/skill/references/components/shadcn.md +19 -0
- package/skill/references/components.md +186 -0
- package/skill/references/extending.md +48 -0
- package/src/ask-sheet.ts +40 -0
- package/src/assets/css.ts +392 -0
- package/src/assets/scripts.ts +78 -0
- package/src/catalog.ts +287 -0
- package/src/cli.ts +178 -0
- package/src/client/doc-events.ts +1108 -0
- package/src/client/entry.ts +27 -0
- package/src/client-js.ts +47 -0
- package/src/component-map.ts +47 -0
- package/src/components/ui/accordion.tsx +77 -0
- package/src/components/ui/alert-dialog.tsx +185 -0
- package/src/components/ui/alert.tsx +76 -0
- package/src/components/ui/aspect-ratio.tsx +22 -0
- package/src/components/ui/attachment.tsx +208 -0
- package/src/components/ui/avatar.tsx +106 -0
- package/src/components/ui/badge.tsx +52 -0
- package/src/components/ui/breadcrumb.tsx +121 -0
- package/src/components/ui/bubble.tsx +128 -0
- package/src/components/ui/button-group.tsx +88 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/calendar.tsx +226 -0
- package/src/components/ui/card.tsx +102 -0
- package/src/components/ui/carousel.tsx +246 -0
- package/src/components/ui/chart.tsx +379 -0
- package/src/components/ui/checkbox.tsx +27 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/combobox.tsx +298 -0
- package/src/components/ui/command.tsx +193 -0
- package/src/components/ui/context-menu.tsx +271 -0
- package/src/components/ui/dialog.tsx +159 -0
- package/src/components/ui/direction.tsx +4 -0
- package/src/components/ui/drawer.tsx +227 -0
- package/src/components/ui/dropdown-menu.tsx +269 -0
- package/src/components/ui/empty.tsx +104 -0
- package/src/components/ui/field.tsx +237 -0
- package/src/components/ui/fieldset.tsx +32 -0
- package/src/components/ui/frame.tsx +87 -0
- package/src/components/ui/hover-card.tsx +50 -0
- package/src/components/ui/input-group.tsx +159 -0
- package/src/components/ui/input-otp.tsx +83 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/item.tsx +202 -0
- package/src/components/ui/kbd.tsx +26 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/marker.tsx +71 -0
- package/src/components/ui/menubar.tsx +284 -0
- package/src/components/ui/message-scroller.tsx +128 -0
- package/src/components/ui/message.tsx +91 -0
- package/src/components/ui/meter.tsx +80 -0
- package/src/components/ui/native-select.tsx +64 -0
- package/src/components/ui/navigation-menu.tsx +170 -0
- package/src/components/ui/pagination.tsx +133 -0
- package/src/components/ui/popover.tsx +87 -0
- package/src/components/ui/progress.tsx +82 -0
- package/src/components/ui/questionnaire.tsx +328 -0
- package/src/components/ui/radio-group.tsx +35 -0
- package/src/components/ui/resizable.tsx +49 -0
- package/src/components/ui/scroll-area.tsx +50 -0
- package/src/components/ui/select.tsx +201 -0
- package/src/components/ui/separator.tsx +22 -0
- package/src/components/ui/sheet.tsx +135 -0
- package/src/components/ui/sidebar.tsx +730 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/slider.tsx +51 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +31 -0
- package/src/components/ui/table.tsx +113 -0
- package/src/components/ui/tabs.tsx +82 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/toast.tsx +229 -0
- package/src/components/ui/toggle-group.tsx +87 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +65 -0
- package/src/components.ts +78 -0
- package/src/config.ts +56 -0
- package/src/define.ts +122 -0
- package/src/doc-context.ts +20 -0
- package/src/editor.ts +94 -0
- package/src/format-error.ts +78 -0
- package/src/guards.ts +84 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/html.ts +91 -0
- package/src/hydrate/export-index.ts +232 -0
- package/src/hydrate/import-scan.ts +169 -0
- package/src/hydrate/plugins.ts +118 -0
- package/src/hydrate/runtime-module.ts +145 -0
- package/src/hydrate-runtime.ts +67 -0
- package/src/hydrate.ts +147 -0
- package/src/index.ts +16 -0
- package/src/init.ts +57 -0
- package/src/langs.ts +148 -0
- package/src/lines.ts +53 -0
- package/src/load-user-module.ts +191 -0
- package/src/mdx.ts +248 -0
- package/src/paths.ts +36 -0
- package/src/rehype/shiki.ts +533 -0
- package/src/remark/alerts.ts +53 -0
- package/src/remark/ast.ts +96 -0
- package/src/remark/callouts.ts +25 -0
- package/src/remark/code-file.ts +85 -0
- package/src/remark/code-meta.ts +21 -0
- package/src/remark/directives.ts +170 -0
- package/src/remark/file-paths.ts +64 -0
- package/src/remark/headings.ts +131 -0
- package/src/remark/no-js.ts +40 -0
- package/src/render.ts +337 -0
- package/src/serve.ts +322 -0
- package/src/styles/globals.css +134 -0
- package/src/styles/shadcn.css +641 -0
- package/src/tailwind.ts +119 -0
- package/src/ui/approvals.tsx +76 -0
- package/src/ui/ask-question.tsx +386 -0
- package/src/ui/ask.tsx +206 -0
- package/src/ui/attrs.ts +51 -0
- package/src/ui/audit.tsx +139 -0
- package/src/ui/bar-chart.tsx +334 -0
- package/src/ui/benchmarks.tsx +143 -0
- package/src/ui/bits.tsx +537 -0
- package/src/ui/board.tsx +173 -0
- package/src/ui/bridge.tsx +207 -0
- package/src/ui/bumps.tsx +178 -0
- package/src/ui/callout.tsx +106 -0
- package/src/ui/changes.tsx +89 -0
- package/src/ui/chart-bits.tsx +52 -0
- package/src/ui/chart.ts +577 -0
- package/src/ui/checks.tsx +203 -0
- package/src/ui/child-index.tsx +44 -0
- package/src/ui/children.ts +43 -0
- package/src/ui/chips.tsx +49 -0
- package/src/ui/cmd.tsx +27 -0
- package/src/ui/columns.tsx +31 -0
- package/src/ui/comments.tsx +544 -0
- package/src/ui/compare.tsx +67 -0
- package/src/ui/decision.tsx +79 -0
- package/src/ui/deps.tsx +78 -0
- package/src/ui/details.tsx +43 -0
- package/src/ui/diff-parse.ts +307 -0
- package/src/ui/diff.tsx +458 -0
- package/src/ui/diffstat.tsx +58 -0
- package/src/ui/due.tsx +65 -0
- package/src/ui/effort.tsx +29 -0
- package/src/ui/endpoints.tsx +118 -0
- package/src/ui/envvars.tsx +96 -0
- package/src/ui/figure.tsx +37 -0
- package/src/ui/file-icon.ts +1020 -0
- package/src/ui/file-link.ts +32 -0
- package/src/ui/file-ref.tsx +39 -0
- package/src/ui/files.tsx +112 -0
- package/src/ui/findings.tsx +85 -0
- package/src/ui/flow.tsx +73 -0
- package/src/ui/funnel.tsx +121 -0
- package/src/ui/gantt.tsx +443 -0
- package/src/ui/gauges.tsx +135 -0
- package/src/ui/glossary.tsx +29 -0
- package/src/ui/graph-layout.ts +149 -0
- package/src/ui/graph-specs.tsx +102 -0
- package/src/ui/graph.tsx +278 -0
- package/src/ui/grid.tsx +119 -0
- package/src/ui/hypothesis.tsx +94 -0
- package/src/ui/icon.tsx +80 -0
- package/src/ui/incident.tsx +130 -0
- package/src/ui/index.ts +342 -0
- package/src/ui/ins-del.tsx +41 -0
- package/src/ui/json.tsx +190 -0
- package/src/ui/layout.ts +9 -0
- package/src/ui/line-chart.tsx +251 -0
- package/src/ui/matrix.tsx +208 -0
- package/src/ui/meta.tsx +73 -0
- package/src/ui/option.tsx +64 -0
- package/src/ui/owner.tsx +40 -0
- package/src/ui/packages.tsx +104 -0
- package/src/ui/pathway.tsx +96 -0
- package/src/ui/phase.tsx +41 -0
- package/src/ui/pie-chart.tsx +170 -0
- package/src/ui/plan.tsx +77 -0
- package/src/ui/pre.tsx +175 -0
- package/src/ui/priority.tsx +51 -0
- package/src/ui/props.tsx +77 -0
- package/src/ui/quadrant.tsx +172 -0
- package/src/ui/radar.tsx +200 -0
- package/src/ui/ref.tsx +160 -0
- package/src/ui/release.tsx +178 -0
- package/src/ui/req.tsx +42 -0
- package/src/ui/review.tsx +133 -0
- package/src/ui/risk.tsx +67 -0
- package/src/ui/sankey.tsx +287 -0
- package/src/ui/scatter.tsx +237 -0
- package/src/ui/schema.tsx +113 -0
- package/src/ui/score.tsx +105 -0
- package/src/ui/search.tsx +120 -0
- package/src/ui/series.tsx +38 -0
- package/src/ui/severity.tsx +69 -0
- package/src/ui/shadcn.tsx +216 -0
- package/src/ui/spark.tsx +86 -0
- package/src/ui/stack.tsx +57 -0
- package/src/ui/stats.tsx +63 -0
- package/src/ui/status-badge.tsx +66 -0
- package/src/ui/statuspage.tsx +238 -0
- package/src/ui/steps.tsx +74 -0
- package/src/ui/summary.tsx +41 -0
- package/src/ui/symbol-ref.tsx +73 -0
- package/src/ui/terminal.tsx +117 -0
- package/src/ui/tests.tsx +218 -0
- package/src/ui/timeline.tsx +63 -0
- package/src/ui/toc.tsx +56 -0
- package/src/ui/tones.ts +187 -0
- package/src/ui/trace.tsx +69 -0
- package/src/ui/tree.tsx +281 -0
- package/src/ui/treemap.tsx +128 -0
- package/src/ui/venn.tsx +258 -0
- package/src/ui/verdict.tsx +78 -0
- package/src/ui/waterfall.tsx +142 -0
package/src/html.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { icons as lucide } from "@iconify-json/lucide";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
KATEX_CDN_URL,
|
|
5
|
+
LIVE_RELOAD_JS,
|
|
6
|
+
MERMAID_JS,
|
|
7
|
+
THEME_JS,
|
|
8
|
+
} from "./assets/scripts.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* JS destined for an inline `<script>` element: neutralize the two byte
|
|
12
|
+
* sequences the HTML parser treats specially (`</script` ends the element,
|
|
13
|
+
* `<!--` opens a comment escape). `\u003C` keeps the meaning in strings,
|
|
14
|
+
* comments, and regex literals alike. Applied centrally here so every
|
|
15
|
+
* snippet — authored constants, the client bundle, the hydrate bundle —
|
|
16
|
+
* is safe regardless of its producer.
|
|
17
|
+
*/
|
|
18
|
+
export const inlineScript = (js: string): string =>
|
|
19
|
+
js
|
|
20
|
+
.replaceAll(/<\/script/giu, "\\u003C/script")
|
|
21
|
+
.replaceAll("<!--", "\\u003C!--");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Inline `<style>` content: a `</style` byte sequence inside the CSS (e.g. a
|
|
25
|
+
* `content:` string in user theme CSS) would end the element early. `<\/`
|
|
26
|
+
* reads identically inside CSS strings/comments, so it's safe to emit.
|
|
27
|
+
*/
|
|
28
|
+
export const inlineStyle = (css: string): string =>
|
|
29
|
+
css.replaceAll(/<\/style/giu, "<\\/style");
|
|
30
|
+
|
|
31
|
+
const ESCAPES: Record<string, string> = {
|
|
32
|
+
'"': """,
|
|
33
|
+
"&": "&",
|
|
34
|
+
"'": "'",
|
|
35
|
+
"<": "<",
|
|
36
|
+
">": ">",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const escapeHtml = (s: string): string =>
|
|
40
|
+
s.replaceAll(/[&<>"']/gu, (c) => ESCAPES[c] ?? c);
|
|
41
|
+
|
|
42
|
+
/** Inline SVG from the bundled lucide set (bodies carry stroke attrs). */
|
|
43
|
+
const iconSvg = (name: string): string => {
|
|
44
|
+
const icon = lucide.icons[name];
|
|
45
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 ${icon?.width ?? 24} ${icon?.height ?? 24}" class="lucide" aria-hidden="true">${icon?.body ?? ""}</svg>`;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Theme toggle button — document chrome fixed to the top-right corner.
|
|
50
|
+
* `data-mode` selects the visible icon (auto → light → dark, cycled by
|
|
51
|
+
* `handleDocEvent`); THEME_JS restores the stored choice before paint.
|
|
52
|
+
*/
|
|
53
|
+
const THEME_TOGGLE_HTML = `<button type="button" class="mdxr-theme" data-mdxr-theme data-mode="auto" title="Theme: auto" aria-label="Switch theme (current: auto)"><span class="mdxr-theme-i mdxr-theme-i-auto">${iconSvg("sun-moon")}</span><span class="mdxr-theme-i mdxr-theme-i-light">${iconSvg("sun")}</span><span class="mdxr-theme-i mdxr-theme-i-dark">${iconSvg("moon")}</span></button>`;
|
|
54
|
+
|
|
55
|
+
export interface DocumentOptions {
|
|
56
|
+
title: string;
|
|
57
|
+
body: string;
|
|
58
|
+
css: string;
|
|
59
|
+
/** Vanilla client bundle from `clientJs()` (copy/ask/theme handlers). */
|
|
60
|
+
clientJs: string;
|
|
61
|
+
needsMermaid: boolean;
|
|
62
|
+
needsKatex?: boolean;
|
|
63
|
+
liveReload?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Client bundle that `hydrateRoot`s the compiled MDX module onto
|
|
66
|
+
* `<main id="mdxr-root">`, making Base UI primitives interactive.
|
|
67
|
+
*/
|
|
68
|
+
hydrateJs?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const htmlDocument = (o: DocumentOptions): string => `<!doctype html>
|
|
72
|
+
<html lang="en">
|
|
73
|
+
<head>
|
|
74
|
+
<meta charset="utf-8">
|
|
75
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
76
|
+
<meta name="generator" content="mdxr">
|
|
77
|
+
<title>${escapeHtml(o.title)}</title>
|
|
78
|
+
<script>${inlineScript(THEME_JS)}</script>
|
|
79
|
+
${o.needsKatex === true ? `<link rel="stylesheet" href="${KATEX_CDN_URL}">` : ""}
|
|
80
|
+
<style>${inlineStyle(o.css)}</style>
|
|
81
|
+
</head>
|
|
82
|
+
<body class="bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100">
|
|
83
|
+
${THEME_TOGGLE_HTML}
|
|
84
|
+
<main id="mdxr-root" class="prose prose-neutral dark:prose-invert mx-auto max-w-3xl px-6 py-10">${o.body}</main>
|
|
85
|
+
<script>${inlineScript(o.clientJs)}</script>
|
|
86
|
+
${o.needsMermaid ? `<script type="module">${inlineScript(MERMAID_JS)}</script>` : ""}
|
|
87
|
+
${o.liveReload === true ? `<script>${inlineScript(LIVE_RELOAD_JS)}</script>` : ""}
|
|
88
|
+
${o.hydrateJs === undefined ? "" : `<script>${inlineScript(o.hydrateJs)}</script>`}
|
|
89
|
+
</body>
|
|
90
|
+
</html>
|
|
91
|
+
`;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export-surface scanning for the hydration bundle: which leaf module
|
|
3
|
+
* provides each public name, and which names each public specifier
|
|
4
|
+
* (`@suzumiyaaoba/mdxr`, `…/components`) actually exports. Derived by
|
|
5
|
+
* scanning the
|
|
6
|
+
* leaf modules under `src/ui/` + `src/components/ui/` rather than parsing
|
|
7
|
+
* barrels: any export form (`export *`, `export const`, `export {X}` lists,
|
|
8
|
+
* re-export chains like ask→ask-question) resolves correctly, and new leaf
|
|
9
|
+
* files self-register.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
import { srcDir } from "../paths.js";
|
|
17
|
+
import { shadcnModules } from "../ui/shadcn.js";
|
|
18
|
+
|
|
19
|
+
let exportIndexCache: ExportIndex | undefined;
|
|
20
|
+
|
|
21
|
+
const DECLARE_RE = /export\s+(?:const|function|class)\s+(?<name>\w+)/gu;
|
|
22
|
+
const REEXPORT_RE =
|
|
23
|
+
/export\s+(?!type\b)\{(?<names>[^}]*)\}\s*from\s*"(?<mod>\.[^"]+)"/gu;
|
|
24
|
+
/** `export * as name from "x"` — the exported name is `name`. */
|
|
25
|
+
const STAR_AS_RE = /export\s+\*\s+as\s+(?<name>\w+)\s+from\s*"[^"]+"/gu;
|
|
26
|
+
/** `export * from "./x.js"` — the surface walk follows relative targets. */
|
|
27
|
+
const STAR_RE = /export\s+\*\s+from\s*"(?<mod>\.[^"]+)"/gu;
|
|
28
|
+
/** `export { A, B as C }` with no `from` — local re-export list (shadcn style). */
|
|
29
|
+
const LOCAL_EXPORT_RE = /export\s+(?!type\b)\{(?<names>[^}]*)\}(?!\s*from\b)/gu;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `{ A, type B, C as D }` clause → runtime names importers see (`A`, `D`).
|
|
33
|
+
* `type`-marked parts are erased at runtime — emitting `export { B } from "m"`
|
|
34
|
+
* for one would explode the whole bundle with "no matching export".
|
|
35
|
+
*/
|
|
36
|
+
const exportedNames = (names: string | undefined): string[] =>
|
|
37
|
+
(names ?? "")
|
|
38
|
+
.split(",")
|
|
39
|
+
.map((part) => part.trim())
|
|
40
|
+
.filter((part) => part !== "" && !part.startsWith("type "))
|
|
41
|
+
.map(
|
|
42
|
+
(part) =>
|
|
43
|
+
part
|
|
44
|
+
.split(/\s+as\s+/u)
|
|
45
|
+
.pop()
|
|
46
|
+
?.trim() ?? ""
|
|
47
|
+
)
|
|
48
|
+
.filter((name) => name !== "");
|
|
49
|
+
|
|
50
|
+
/** `[exportName, target]` pairs a leaf module re-exports (`export {X as Y} from "./z"`). */
|
|
51
|
+
const reexportTargets = (
|
|
52
|
+
source: string,
|
|
53
|
+
dir: string
|
|
54
|
+
): (readonly [string, string])[] => {
|
|
55
|
+
const pairs: (readonly [string, string])[] = [];
|
|
56
|
+
for (const m of source.matchAll(REEXPORT_RE)) {
|
|
57
|
+
const mod = path.join(dir, m.groups?.mod ?? "");
|
|
58
|
+
for (const name of exportedNames(m.groups?.names)) {
|
|
59
|
+
pairs.push([name, mod]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return pairs;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** A `./x.js` specifier in our sources maps to `./x.ts`/`.tsx` on disk. */
|
|
66
|
+
const sourceFile = (spec: string, dir: string): string | undefined => {
|
|
67
|
+
const stem = path.resolve(dir, spec).replace(/\.js$/u, "");
|
|
68
|
+
for (const ext of [".ts", ".tsx", ".js"]) {
|
|
69
|
+
const p = `${stem}${ext}`;
|
|
70
|
+
if (existsSync(p)) {
|
|
71
|
+
return p;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Every runtime-exported name in `source` lands in `out`. */
|
|
78
|
+
const collectNames = (source: string, out: Set<string>): void => {
|
|
79
|
+
for (const m of source.matchAll(DECLARE_RE)) {
|
|
80
|
+
const name = m.groups?.name;
|
|
81
|
+
if (name !== undefined) {
|
|
82
|
+
out.add(name);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const m of source.matchAll(STAR_AS_RE)) {
|
|
86
|
+
const name = m.groups?.name;
|
|
87
|
+
if (name !== undefined) {
|
|
88
|
+
out.add(name);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const re of [REEXPORT_RE, LOCAL_EXPORT_RE]) {
|
|
92
|
+
for (const m of source.matchAll(re)) {
|
|
93
|
+
for (const name of exportedNames(m.groups?.names)) {
|
|
94
|
+
out.add(name);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Every name `entry` re-exports, chasing `export *` targets — the surface the
|
|
102
|
+
* real package shows importers. Type-only exports never match the regexes
|
|
103
|
+
* (`export type`, `type X` parts), so the set holds runtime names only.
|
|
104
|
+
*/
|
|
105
|
+
const collectSurface = async (entry: string): Promise<Set<string>> => {
|
|
106
|
+
const out = new Set<string>();
|
|
107
|
+
const seen = new Set<string>();
|
|
108
|
+
const walk = async (file: string): Promise<void> => {
|
|
109
|
+
if (seen.has(file)) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
seen.add(file);
|
|
113
|
+
let source: string;
|
|
114
|
+
try {
|
|
115
|
+
source = await readFile(file, "utf-8");
|
|
116
|
+
} catch {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
collectNames(source, out);
|
|
120
|
+
const dir = path.dirname(file);
|
|
121
|
+
const starTargets: string[] = [];
|
|
122
|
+
for (const m of source.matchAll(STAR_RE)) {
|
|
123
|
+
const mod = m.groups?.mod;
|
|
124
|
+
if (mod === undefined) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const target = sourceFile(mod, dir);
|
|
128
|
+
if (target !== undefined) {
|
|
129
|
+
starTargets.push(target);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
await Promise.all(starTargets.map(walk));
|
|
133
|
+
};
|
|
134
|
+
await walk(entry);
|
|
135
|
+
return out;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export interface ExportIndex {
|
|
139
|
+
/** export name → leaf module, for every name a public entry can produce. */
|
|
140
|
+
map: Map<string, string>;
|
|
141
|
+
/**
|
|
142
|
+
* Names each public specifier actually exports — the virtual module must
|
|
143
|
+
* not resolve beyond this, or the client would see a real value where SSR's
|
|
144
|
+
* import failed or bound `undefined` (e.g. `mountDocument`, leaf internals
|
|
145
|
+
* like `fileIcon` under the root entry, or `v` under `/components`).
|
|
146
|
+
*/
|
|
147
|
+
surfaces: { components: Set<string>; mdxr: Set<string> };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* All `name → module` pairs a leaf file claims — declarations plus every
|
|
152
|
+
* re-export form. Returned rather than written into a shared map: callers
|
|
153
|
+
* merge in sorted filename order, so a duplicated export name resolves
|
|
154
|
+
* deterministically instead of racing whichever `readFile` finished last.
|
|
155
|
+
*/
|
|
156
|
+
const scanLeaf = async (
|
|
157
|
+
filePath: string
|
|
158
|
+
): Promise<(readonly [string, string])[]> => {
|
|
159
|
+
const source = await readFile(filePath, "utf-8");
|
|
160
|
+
const dir = path.dirname(filePath);
|
|
161
|
+
const pairs: (readonly [string, string])[] = [];
|
|
162
|
+
for (const m of source.matchAll(DECLARE_RE)) {
|
|
163
|
+
const name = m.groups?.name;
|
|
164
|
+
if (name !== undefined) {
|
|
165
|
+
pairs.push([name, filePath]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const [name, mod] of reexportTargets(source, dir)) {
|
|
169
|
+
pairs.push([name, mod]);
|
|
170
|
+
}
|
|
171
|
+
for (const m of source.matchAll(LOCAL_EXPORT_RE)) {
|
|
172
|
+
for (const name of exportedNames(m.groups?.names)) {
|
|
173
|
+
pairs.push([name, filePath]);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const m of source.matchAll(STAR_AS_RE)) {
|
|
177
|
+
const name = m.groups?.name;
|
|
178
|
+
if (name !== undefined) {
|
|
179
|
+
pairs.push([name, filePath]);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return pairs;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* export name → module specifier for every name the catalog can produce,
|
|
187
|
+
* plus the real export surface of each public specifier.
|
|
188
|
+
*/
|
|
189
|
+
// Exported for tests — the surface contract is what keeps SSR and the
|
|
190
|
+
// hydration bundle in agreement; it deserves direct assertions.
|
|
191
|
+
export const exportIndex = async (): Promise<ExportIndex> => {
|
|
192
|
+
if (exportIndexCache !== undefined) {
|
|
193
|
+
return exportIndexCache;
|
|
194
|
+
}
|
|
195
|
+
const map = new Map<string, string>([
|
|
196
|
+
["DocContext", path.join(srcDir, "doc-context.js")],
|
|
197
|
+
]);
|
|
198
|
+
const leafDirs = [
|
|
199
|
+
path.join(srcDir, "ui"),
|
|
200
|
+
path.join(srcDir, "components/ui"),
|
|
201
|
+
];
|
|
202
|
+
const scanned = await Promise.all(
|
|
203
|
+
leafDirs.map(async (dir) => {
|
|
204
|
+
const entries = await readdir(dir);
|
|
205
|
+
const files = entries
|
|
206
|
+
.filter((f) => /\.tsx?$/u.test(f) && f !== "index.ts")
|
|
207
|
+
.toSorted();
|
|
208
|
+
return await Promise.all(
|
|
209
|
+
files.map(async (f) => await scanLeaf(path.join(dir, f)))
|
|
210
|
+
);
|
|
211
|
+
})
|
|
212
|
+
);
|
|
213
|
+
// Serial merge in sorted order — the parallel scans above must not make
|
|
214
|
+
// a name collision's winner depend on I/O timing.
|
|
215
|
+
for (const pairs of scanned.flat()) {
|
|
216
|
+
for (const [name, mod] of pairs) {
|
|
217
|
+
map.set(name, mod);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const [name, mod] of Object.entries(shadcnModules)) {
|
|
221
|
+
map.set(name, path.join(srcDir, "ui", mod));
|
|
222
|
+
}
|
|
223
|
+
const index: ExportIndex = {
|
|
224
|
+
map,
|
|
225
|
+
surfaces: {
|
|
226
|
+
components: await collectSurface(path.join(srcDir, "components.ts")),
|
|
227
|
+
mdxr: await collectSurface(path.join(srcDir, "index.ts")),
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
exportIndexCache = index;
|
|
231
|
+
return index;
|
|
232
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-module import scanning: which names an importer pulls from
|
|
3
|
+
* `@suzumiyaaoba/mdxr`(/components), and which valibot properties it accesses
|
|
4
|
+
* through
|
|
5
|
+
* an imported `v` alias. The virtual runtime module generated for each
|
|
6
|
+
* importer contains only what it requests — a full-surface module would
|
|
7
|
+
* drag the whole catalog into every hydration bundle.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
|
|
12
|
+
export interface MdxrImports {
|
|
13
|
+
/** Named imports the file requests, or `"all"` when it needs the whole API. */
|
|
14
|
+
names: Set<string> | "all";
|
|
15
|
+
/**
|
|
16
|
+
* Properties accessed on the imported `v`, or `"all"` when `v` escapes
|
|
17
|
+
* (bare use, re-export, dynamic access) so the whole namespace is needed.
|
|
18
|
+
* `null` when `v` is not imported.
|
|
19
|
+
*/
|
|
20
|
+
vProps: Set<string> | "all" | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Properties accessed on `alias` (`v`'s local name) — or `"all"` when the
|
|
25
|
+
* namespace escapes (bare use, `alias[key]`, spread, re-export) and the full
|
|
26
|
+
* valibot namespace must be shipped.
|
|
27
|
+
*/
|
|
28
|
+
const scanVProps = (stripped: string, alias: string): Set<string> | "all" => {
|
|
29
|
+
const esc = alias.replaceAll(/[$()*+.?[\\\]^{|}]/gu, "\\$&");
|
|
30
|
+
const access = new RegExp(
|
|
31
|
+
`\\b${esc}\\s*\\?\\.\\s*(\\w+)|\\b${esc}\\s*\\.\\s*(\\w+)`,
|
|
32
|
+
"gu"
|
|
33
|
+
);
|
|
34
|
+
const props = new Set<string>();
|
|
35
|
+
for (const use of stripped.matchAll(access)) {
|
|
36
|
+
const prop = use[1] ?? use[2];
|
|
37
|
+
if (prop !== undefined) {
|
|
38
|
+
props.add(prop);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const leftover = stripped.replaceAll(access, "");
|
|
42
|
+
return new RegExp(`\\b${esc}\\b`, "u").test(leftover) ? "all" : props;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `/* … *\/` and `// …` are legal inside a multiline import clause, and a
|
|
47
|
+
* name polluted with comment text would silently fail the surface check and
|
|
48
|
+
* kill the hydration build. Clauses never contain string literals, so a
|
|
49
|
+
* regex strip is safe here.
|
|
50
|
+
*/
|
|
51
|
+
const stripComments = (s: string): string =>
|
|
52
|
+
s.replaceAll(/\/\*[\s\S]*?\*\//gu, "").replaceAll(/\/\/[^\n]*/gu, "");
|
|
53
|
+
|
|
54
|
+
/** Parse `{ a, b as c, type T }` import specifiers into names + `v` aliases. */
|
|
55
|
+
const parseClause = (
|
|
56
|
+
clause: string,
|
|
57
|
+
names: Set<string>,
|
|
58
|
+
vAliases: string[]
|
|
59
|
+
): void => {
|
|
60
|
+
// The clause may trail past `}` — comments between `}` and `from` are legal
|
|
61
|
+
// (`import {a} /* x */ from "m"`) and the regex captures them. Strip
|
|
62
|
+
// comments first (a `}` inside one is comment text), then cut at the last
|
|
63
|
+
// `}` so `{ a } /* c */` reads as `a`, not `a }`.
|
|
64
|
+
const inner = stripComments(clause);
|
|
65
|
+
const close = inner.lastIndexOf("}");
|
|
66
|
+
const body = close === -1 ? inner.slice(1) : inner.slice(1, close);
|
|
67
|
+
for (const part of body.split(",")) {
|
|
68
|
+
const spec = part.trim().replace(/^type\s+/u, "");
|
|
69
|
+
const [imported, local] = spec.split(/\s+as\s+/u).map((s) => s?.trim());
|
|
70
|
+
if (imported === undefined || imported === "") {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
names.add(imported);
|
|
74
|
+
if (imported === "v") {
|
|
75
|
+
vAliases.push(local ?? "v");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Merge per-alias prop scans — `"all"` once any alias escapes. */
|
|
81
|
+
const mergeVProps = (
|
|
82
|
+
stripped: string,
|
|
83
|
+
vAliases: string[],
|
|
84
|
+
escapes: boolean
|
|
85
|
+
): Set<string> | "all" | null => {
|
|
86
|
+
if (escapes) {
|
|
87
|
+
return "all";
|
|
88
|
+
}
|
|
89
|
+
let vProps: Set<string> | null = null;
|
|
90
|
+
for (const alias of vAliases) {
|
|
91
|
+
const props = scanVProps(stripped, alias);
|
|
92
|
+
if (props === "all") {
|
|
93
|
+
return "all";
|
|
94
|
+
}
|
|
95
|
+
vProps = vProps === null ? props : new Set([...vProps, ...props]);
|
|
96
|
+
}
|
|
97
|
+
return vProps;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Which names `importer` pulls from `@suzumiyaaoba/mdxr`(/components),
|
|
102
|
+
* extracted with a
|
|
103
|
+
* regex over its source. `names`/`vProps` are `"all"` for namespace, default,
|
|
104
|
+
* bare, or dynamic imports and for uninspectable importers — the fallback
|
|
105
|
+
* keeps semantics correct at the cost of emitting every catalog re-export.
|
|
106
|
+
*/
|
|
107
|
+
export const scanMdxrImports = async (
|
|
108
|
+
importer: string
|
|
109
|
+
): Promise<MdxrImports> => {
|
|
110
|
+
let source: string;
|
|
111
|
+
try {
|
|
112
|
+
source = await readFile(importer, "utf-8");
|
|
113
|
+
} catch {
|
|
114
|
+
return { names: "all", vProps: "all" };
|
|
115
|
+
}
|
|
116
|
+
const names = new Set<string>();
|
|
117
|
+
const vAliases: string[] = [];
|
|
118
|
+
// `\s*` not `\s+`: `import{v}from"@suzumiyaaoba/mdxr"` is legal and must
|
|
119
|
+
// still register.
|
|
120
|
+
// `(?!\s*type\b)` puts the whitespace inside the lookahead — a `\s*` outside
|
|
121
|
+
// would backtrack to zero and let `import type` slip through as a runtime
|
|
122
|
+
// import (over-shipping the whole surface). The clause pattern alternates
|
|
123
|
+
// comments with plain chars so a `;`, `'`, or `"` inside a comment doesn't
|
|
124
|
+
// truncate the clause and hide the whole import.
|
|
125
|
+
const fromRe =
|
|
126
|
+
/(?:import|export)\s*(?!\s*type\b)(?<clause>(?:\/\*[\s\S]*?\*\/|\/\/[^\n]*|[^;"'])*?)\s*from\s*["']@suzumiyaaoba\/mdxr(?:\/components)?["']/gu;
|
|
127
|
+
let stripped = source;
|
|
128
|
+
// `export { v } from "@suzumiyaaoba/mdxr"` hands the namespace to consumers —
|
|
129
|
+
// every
|
|
130
|
+
// property is reachable, so the per-prop access scan can't apply.
|
|
131
|
+
let vEscapes = false;
|
|
132
|
+
for (const m of source.matchAll(fromRe)) {
|
|
133
|
+
const clause = m.groups?.clause ?? "";
|
|
134
|
+
stripped = stripped.replace(m[0], "");
|
|
135
|
+
if (!clause.startsWith("{")) {
|
|
136
|
+
return { names: "all", vProps: "all" };
|
|
137
|
+
}
|
|
138
|
+
const found = vAliases.length;
|
|
139
|
+
parseClause(clause, names, vAliases);
|
|
140
|
+
vEscapes ||= m[0].startsWith("export") && vAliases.length > found;
|
|
141
|
+
}
|
|
142
|
+
// Type-only imports don't run, but a leftover `import type { v }` would
|
|
143
|
+
// still trip the `\bv\b` leftover scan and force-ship all of valibot.
|
|
144
|
+
stripped = stripped.replaceAll(
|
|
145
|
+
/(?:import|export)\s+type\s[^;]*?(?:;|$)/gmu,
|
|
146
|
+
""
|
|
147
|
+
);
|
|
148
|
+
// Bare/dynamic imports escape analysis entirely. The separator allows
|
|
149
|
+
// comments — `import /* x */ ("@suzumiyaaoba/mdxr")` is legal and must
|
|
150
|
+
// still count.
|
|
151
|
+
const sep = String.raw`(?:\s|/\*[\s\S]*?\*/|//[^\n]*)*`;
|
|
152
|
+
if (
|
|
153
|
+
new RegExp(
|
|
154
|
+
`import${sep}["']@suzumiyaaoba/mdxr(?:/components)?["']`,
|
|
155
|
+
"u"
|
|
156
|
+
).test(source) ||
|
|
157
|
+
new RegExp(
|
|
158
|
+
`import${sep}\\(${sep}["']@suzumiyaaoba/mdxr(?:/components)?["']`,
|
|
159
|
+
"u"
|
|
160
|
+
).test(source)
|
|
161
|
+
) {
|
|
162
|
+
return { names: "all", vProps: "all" };
|
|
163
|
+
}
|
|
164
|
+
const vProps = mergeVProps(stripped, vAliases, vEscapes);
|
|
165
|
+
return {
|
|
166
|
+
names,
|
|
167
|
+
vProps: vAliases.length === 0 ? null : (vProps ?? new Set()),
|
|
168
|
+
};
|
|
169
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* esbuild plugins for the hydration bundle: pin shared packages to mdxr's own
|
|
3
|
+
* node_modules (a second React copy would break hooks/context), feed the
|
|
4
|
+
* compiled MDX module in as a virtual import, and shrink bundled icon sets to
|
|
5
|
+
* the names a document actually rendered.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { icons as lucide } from "@iconify-json/lucide";
|
|
9
|
+
import { icons as vscodeIcons } from "@iconify-json/vscode-icons";
|
|
10
|
+
import type { Plugin } from "esbuild";
|
|
11
|
+
|
|
12
|
+
import { SHARED_PACKAGES } from "../load-user-module.js";
|
|
13
|
+
import { pkgRoot, srcDir } from "../paths.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Everything the document bundle shares with mdxr itself — React above all
|
|
17
|
+
* (a second copy would break hooks/context) — is resolved from this package's
|
|
18
|
+
* own node_modules regardless of where the user's components live.
|
|
19
|
+
* (esbuild serializes onResolve filters to Go's RE2: no `u` flag.)
|
|
20
|
+
*/
|
|
21
|
+
/* oxlint-disable require-unicode-regexp -- esbuild onResolve filters forbid `u` */
|
|
22
|
+
const SHARED_RE = new RegExp(`^(?:${SHARED_PACKAGES.join("|")})(?:/.*)?$`);
|
|
23
|
+
|
|
24
|
+
export const pinShared: Plugin = {
|
|
25
|
+
name: "mdxr:pin-shared",
|
|
26
|
+
setup(b) {
|
|
27
|
+
b.onResolve({ filter: SHARED_RE }, async (args) => {
|
|
28
|
+
// b.resolve re-enters this plugin — pluginData marks the inner call so
|
|
29
|
+
// the recursion stops after exactly one delegation.
|
|
30
|
+
if (args.pluginData === pinShared) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const r = await b.resolve(args.path, {
|
|
34
|
+
kind: args.kind,
|
|
35
|
+
pluginData: pinShared,
|
|
36
|
+
resolveDir: pkgRoot,
|
|
37
|
+
});
|
|
38
|
+
return r.errors.length === 0 ? { path: r.path } : null;
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
/* oxlint-enable require-unicode-regexp */
|
|
43
|
+
|
|
44
|
+
/** The compiled MDX module enters the bundle as a virtual `mdxr:doc` import. */
|
|
45
|
+
/* oxlint-disable require-unicode-regexp -- esbuild onResolve/onLoad filters forbid `u` */
|
|
46
|
+
export const docModule = (code: string): Plugin => ({
|
|
47
|
+
name: "mdxr:doc",
|
|
48
|
+
setup(b) {
|
|
49
|
+
b.onResolve({ filter: /^mdxr:doc$/ }, () => ({
|
|
50
|
+
namespace: "mdxr-doc",
|
|
51
|
+
path: "mdxr:doc",
|
|
52
|
+
}));
|
|
53
|
+
b.onLoad({ filter: /^mdxr:doc$/, namespace: "mdxr-doc" }, () => ({
|
|
54
|
+
contents: code,
|
|
55
|
+
loader: "js",
|
|
56
|
+
resolveDir: srcDir,
|
|
57
|
+
}));
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
/* oxlint-enable require-unicode-regexp */
|
|
61
|
+
|
|
62
|
+
const ICON_SETS: Record<string, typeof lucide> = {
|
|
63
|
+
"@iconify-json/lucide": lucide,
|
|
64
|
+
"@iconify-json/vscode-icons": vscodeIcons,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The bundled icon sets are several MB of JSON; a document only ever renders
|
|
69
|
+
* the icons recorded during SSR. Redirecting each `@iconify-json/*` import to
|
|
70
|
+
* a partial collection (used names only, alias parents included) keeps
|
|
71
|
+
* `addCollection` calls working while shipping kilobytes, not megabytes.
|
|
72
|
+
*/
|
|
73
|
+
/* oxlint-disable require-unicode-regexp -- esbuild onResolve/onLoad filters forbid `u` */
|
|
74
|
+
export const iconSets = (usedIcons: readonly string[]): Plugin => ({
|
|
75
|
+
name: "mdxr:icons",
|
|
76
|
+
setup(b) {
|
|
77
|
+
b.onResolve({ filter: /^@iconify-json\// }, (args) => ({
|
|
78
|
+
namespace: "mdxr-icons",
|
|
79
|
+
path: args.path,
|
|
80
|
+
}));
|
|
81
|
+
b.onLoad(
|
|
82
|
+
{ filter: /^@iconify-json\//, namespace: "mdxr-icons" },
|
|
83
|
+
(args) => {
|
|
84
|
+
const set = ICON_SETS[args.path];
|
|
85
|
+
if (set === undefined) {
|
|
86
|
+
return { contents: "export const icons = {};", loader: "js" };
|
|
87
|
+
}
|
|
88
|
+
const icons: Record<string, unknown> = {};
|
|
89
|
+
const aliases: Record<string, unknown> = {};
|
|
90
|
+
for (const full of usedIcons) {
|
|
91
|
+
const [prefix, name] = full.split(":");
|
|
92
|
+
if (prefix !== set.prefix || name === undefined) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
// hasOwn: names like "toString" must not pull prototype members —
|
|
96
|
+
// a function would serialize as `{}` and ship a phantom icon.
|
|
97
|
+
if (Object.hasOwn(set.icons, name)) {
|
|
98
|
+
icons[name] = set.icons[name];
|
|
99
|
+
}
|
|
100
|
+
const alias = Object.hasOwn(set.aliases ?? {}, name)
|
|
101
|
+
? set.aliases?.[name]
|
|
102
|
+
: undefined;
|
|
103
|
+
if (alias !== undefined) {
|
|
104
|
+
aliases[name] = alias;
|
|
105
|
+
if (Object.hasOwn(set.icons, alias.parent)) {
|
|
106
|
+
icons[alias.parent] = set.icons[alias.parent];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
contents: `export const icons = ${JSON.stringify({ ...set, aliases, icons })};`,
|
|
112
|
+
loader: "js",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
/* oxlint-enable require-unicode-regexp */
|