@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.
Files changed (236) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/dist/cli.d.mts +1 -0
  4. package/dist/cli.mjs +2902 -0
  5. package/dist/components.d.mts +791 -0
  6. package/dist/components.mjs +2 -0
  7. package/dist/config-SI9IyFiC.mjs +184 -0
  8. package/dist/doc-context-CEqzMYKv.d.mts +568 -0
  9. package/dist/index.d.mts +20 -0
  10. package/dist/index.mjs +4 -0
  11. package/dist/ui-GKFD0mx5.mjs +15986 -0
  12. package/package.json +131 -0
  13. package/skill/SKILL.md +49 -0
  14. package/skill/references/components/charts.md +172 -0
  15. package/skill/references/components/document.md +98 -0
  16. package/skill/references/components/forms.md +35 -0
  17. package/skill/references/components/investigation.md +198 -0
  18. package/skill/references/components/layout.md +68 -0
  19. package/skill/references/components/output.md +158 -0
  20. package/skill/references/components/planning.md +155 -0
  21. package/skill/references/components/reports.md +252 -0
  22. package/skill/references/components/shadcn.md +19 -0
  23. package/skill/references/components.md +186 -0
  24. package/skill/references/extending.md +48 -0
  25. package/src/ask-sheet.ts +40 -0
  26. package/src/assets/css.ts +392 -0
  27. package/src/assets/scripts.ts +78 -0
  28. package/src/catalog.ts +287 -0
  29. package/src/cli.ts +178 -0
  30. package/src/client/doc-events.ts +1108 -0
  31. package/src/client/entry.ts +27 -0
  32. package/src/client-js.ts +47 -0
  33. package/src/component-map.ts +47 -0
  34. package/src/components/ui/accordion.tsx +77 -0
  35. package/src/components/ui/alert-dialog.tsx +185 -0
  36. package/src/components/ui/alert.tsx +76 -0
  37. package/src/components/ui/aspect-ratio.tsx +22 -0
  38. package/src/components/ui/attachment.tsx +208 -0
  39. package/src/components/ui/avatar.tsx +106 -0
  40. package/src/components/ui/badge.tsx +52 -0
  41. package/src/components/ui/breadcrumb.tsx +121 -0
  42. package/src/components/ui/bubble.tsx +128 -0
  43. package/src/components/ui/button-group.tsx +88 -0
  44. package/src/components/ui/button.tsx +58 -0
  45. package/src/components/ui/calendar.tsx +226 -0
  46. package/src/components/ui/card.tsx +102 -0
  47. package/src/components/ui/carousel.tsx +246 -0
  48. package/src/components/ui/chart.tsx +379 -0
  49. package/src/components/ui/checkbox.tsx +27 -0
  50. package/src/components/ui/collapsible.tsx +19 -0
  51. package/src/components/ui/combobox.tsx +298 -0
  52. package/src/components/ui/command.tsx +193 -0
  53. package/src/components/ui/context-menu.tsx +271 -0
  54. package/src/components/ui/dialog.tsx +159 -0
  55. package/src/components/ui/direction.tsx +4 -0
  56. package/src/components/ui/drawer.tsx +227 -0
  57. package/src/components/ui/dropdown-menu.tsx +269 -0
  58. package/src/components/ui/empty.tsx +104 -0
  59. package/src/components/ui/field.tsx +237 -0
  60. package/src/components/ui/fieldset.tsx +32 -0
  61. package/src/components/ui/frame.tsx +87 -0
  62. package/src/components/ui/hover-card.tsx +50 -0
  63. package/src/components/ui/input-group.tsx +159 -0
  64. package/src/components/ui/input-otp.tsx +83 -0
  65. package/src/components/ui/input.tsx +19 -0
  66. package/src/components/ui/item.tsx +202 -0
  67. package/src/components/ui/kbd.tsx +26 -0
  68. package/src/components/ui/label.tsx +19 -0
  69. package/src/components/ui/marker.tsx +71 -0
  70. package/src/components/ui/menubar.tsx +284 -0
  71. package/src/components/ui/message-scroller.tsx +128 -0
  72. package/src/components/ui/message.tsx +91 -0
  73. package/src/components/ui/meter.tsx +80 -0
  74. package/src/components/ui/native-select.tsx +64 -0
  75. package/src/components/ui/navigation-menu.tsx +170 -0
  76. package/src/components/ui/pagination.tsx +133 -0
  77. package/src/components/ui/popover.tsx +87 -0
  78. package/src/components/ui/progress.tsx +82 -0
  79. package/src/components/ui/questionnaire.tsx +328 -0
  80. package/src/components/ui/radio-group.tsx +35 -0
  81. package/src/components/ui/resizable.tsx +49 -0
  82. package/src/components/ui/scroll-area.tsx +50 -0
  83. package/src/components/ui/select.tsx +201 -0
  84. package/src/components/ui/separator.tsx +22 -0
  85. package/src/components/ui/sheet.tsx +135 -0
  86. package/src/components/ui/sidebar.tsx +730 -0
  87. package/src/components/ui/skeleton.tsx +13 -0
  88. package/src/components/ui/slider.tsx +51 -0
  89. package/src/components/ui/spinner.tsx +16 -0
  90. package/src/components/ui/switch.tsx +31 -0
  91. package/src/components/ui/table.tsx +113 -0
  92. package/src/components/ui/tabs.tsx +82 -0
  93. package/src/components/ui/textarea.tsx +17 -0
  94. package/src/components/ui/toast.tsx +229 -0
  95. package/src/components/ui/toggle-group.tsx +87 -0
  96. package/src/components/ui/toggle.tsx +43 -0
  97. package/src/components/ui/tooltip.tsx +65 -0
  98. package/src/components.ts +78 -0
  99. package/src/config.ts +56 -0
  100. package/src/define.ts +122 -0
  101. package/src/doc-context.ts +20 -0
  102. package/src/editor.ts +94 -0
  103. package/src/format-error.ts +78 -0
  104. package/src/guards.ts +84 -0
  105. package/src/hooks/use-mobile.ts +21 -0
  106. package/src/html.ts +91 -0
  107. package/src/hydrate/export-index.ts +232 -0
  108. package/src/hydrate/import-scan.ts +169 -0
  109. package/src/hydrate/plugins.ts +118 -0
  110. package/src/hydrate/runtime-module.ts +145 -0
  111. package/src/hydrate-runtime.ts +67 -0
  112. package/src/hydrate.ts +147 -0
  113. package/src/index.ts +16 -0
  114. package/src/init.ts +57 -0
  115. package/src/langs.ts +148 -0
  116. package/src/lines.ts +53 -0
  117. package/src/load-user-module.ts +191 -0
  118. package/src/mdx.ts +248 -0
  119. package/src/paths.ts +36 -0
  120. package/src/rehype/shiki.ts +533 -0
  121. package/src/remark/alerts.ts +53 -0
  122. package/src/remark/ast.ts +96 -0
  123. package/src/remark/callouts.ts +25 -0
  124. package/src/remark/code-file.ts +85 -0
  125. package/src/remark/code-meta.ts +21 -0
  126. package/src/remark/directives.ts +170 -0
  127. package/src/remark/file-paths.ts +64 -0
  128. package/src/remark/headings.ts +131 -0
  129. package/src/remark/no-js.ts +40 -0
  130. package/src/render.ts +337 -0
  131. package/src/serve.ts +322 -0
  132. package/src/styles/globals.css +134 -0
  133. package/src/styles/shadcn.css +641 -0
  134. package/src/tailwind.ts +119 -0
  135. package/src/ui/approvals.tsx +76 -0
  136. package/src/ui/ask-question.tsx +386 -0
  137. package/src/ui/ask.tsx +206 -0
  138. package/src/ui/attrs.ts +51 -0
  139. package/src/ui/audit.tsx +139 -0
  140. package/src/ui/bar-chart.tsx +334 -0
  141. package/src/ui/benchmarks.tsx +143 -0
  142. package/src/ui/bits.tsx +537 -0
  143. package/src/ui/board.tsx +173 -0
  144. package/src/ui/bridge.tsx +207 -0
  145. package/src/ui/bumps.tsx +178 -0
  146. package/src/ui/callout.tsx +106 -0
  147. package/src/ui/changes.tsx +89 -0
  148. package/src/ui/chart-bits.tsx +52 -0
  149. package/src/ui/chart.ts +577 -0
  150. package/src/ui/checks.tsx +203 -0
  151. package/src/ui/child-index.tsx +44 -0
  152. package/src/ui/children.ts +43 -0
  153. package/src/ui/chips.tsx +49 -0
  154. package/src/ui/cmd.tsx +27 -0
  155. package/src/ui/columns.tsx +31 -0
  156. package/src/ui/comments.tsx +544 -0
  157. package/src/ui/compare.tsx +67 -0
  158. package/src/ui/decision.tsx +79 -0
  159. package/src/ui/deps.tsx +78 -0
  160. package/src/ui/details.tsx +43 -0
  161. package/src/ui/diff-parse.ts +307 -0
  162. package/src/ui/diff.tsx +458 -0
  163. package/src/ui/diffstat.tsx +58 -0
  164. package/src/ui/due.tsx +65 -0
  165. package/src/ui/effort.tsx +29 -0
  166. package/src/ui/endpoints.tsx +118 -0
  167. package/src/ui/envvars.tsx +96 -0
  168. package/src/ui/figure.tsx +37 -0
  169. package/src/ui/file-icon.ts +1020 -0
  170. package/src/ui/file-link.ts +32 -0
  171. package/src/ui/file-ref.tsx +39 -0
  172. package/src/ui/files.tsx +112 -0
  173. package/src/ui/findings.tsx +85 -0
  174. package/src/ui/flow.tsx +73 -0
  175. package/src/ui/funnel.tsx +121 -0
  176. package/src/ui/gantt.tsx +443 -0
  177. package/src/ui/gauges.tsx +135 -0
  178. package/src/ui/glossary.tsx +29 -0
  179. package/src/ui/graph-layout.ts +149 -0
  180. package/src/ui/graph-specs.tsx +102 -0
  181. package/src/ui/graph.tsx +278 -0
  182. package/src/ui/grid.tsx +119 -0
  183. package/src/ui/hypothesis.tsx +94 -0
  184. package/src/ui/icon.tsx +80 -0
  185. package/src/ui/incident.tsx +130 -0
  186. package/src/ui/index.ts +342 -0
  187. package/src/ui/ins-del.tsx +41 -0
  188. package/src/ui/json.tsx +190 -0
  189. package/src/ui/layout.ts +9 -0
  190. package/src/ui/line-chart.tsx +251 -0
  191. package/src/ui/matrix.tsx +208 -0
  192. package/src/ui/meta.tsx +73 -0
  193. package/src/ui/option.tsx +64 -0
  194. package/src/ui/owner.tsx +40 -0
  195. package/src/ui/packages.tsx +104 -0
  196. package/src/ui/pathway.tsx +96 -0
  197. package/src/ui/phase.tsx +41 -0
  198. package/src/ui/pie-chart.tsx +170 -0
  199. package/src/ui/plan.tsx +77 -0
  200. package/src/ui/pre.tsx +175 -0
  201. package/src/ui/priority.tsx +51 -0
  202. package/src/ui/props.tsx +77 -0
  203. package/src/ui/quadrant.tsx +172 -0
  204. package/src/ui/radar.tsx +200 -0
  205. package/src/ui/ref.tsx +160 -0
  206. package/src/ui/release.tsx +178 -0
  207. package/src/ui/req.tsx +42 -0
  208. package/src/ui/review.tsx +133 -0
  209. package/src/ui/risk.tsx +67 -0
  210. package/src/ui/sankey.tsx +287 -0
  211. package/src/ui/scatter.tsx +237 -0
  212. package/src/ui/schema.tsx +113 -0
  213. package/src/ui/score.tsx +105 -0
  214. package/src/ui/search.tsx +120 -0
  215. package/src/ui/series.tsx +38 -0
  216. package/src/ui/severity.tsx +69 -0
  217. package/src/ui/shadcn.tsx +216 -0
  218. package/src/ui/spark.tsx +86 -0
  219. package/src/ui/stack.tsx +57 -0
  220. package/src/ui/stats.tsx +63 -0
  221. package/src/ui/status-badge.tsx +66 -0
  222. package/src/ui/statuspage.tsx +238 -0
  223. package/src/ui/steps.tsx +74 -0
  224. package/src/ui/summary.tsx +41 -0
  225. package/src/ui/symbol-ref.tsx +73 -0
  226. package/src/ui/terminal.tsx +117 -0
  227. package/src/ui/tests.tsx +218 -0
  228. package/src/ui/timeline.tsx +63 -0
  229. package/src/ui/toc.tsx +56 -0
  230. package/src/ui/tones.ts +187 -0
  231. package/src/ui/trace.tsx +69 -0
  232. package/src/ui/tree.tsx +281 -0
  233. package/src/ui/treemap.tsx +128 -0
  234. package/src/ui/venn.tsx +258 -0
  235. package/src/ui/verdict.tsx +78 -0
  236. 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
+ '"': "&quot;",
33
+ "&": "&amp;",
34
+ "'": "&#39;",
35
+ "<": "&lt;",
36
+ ">": "&gt;",
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 */