@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/render.ts ADDED
@@ -0,0 +1,337 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile, readdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { createElement } from "react";
6
+
7
+ import { clientJs } from "./client-js.js";
8
+ import { mergeUserComponents } from "./component-map.js";
9
+ import type { ResolvedConfig } from "./config.js";
10
+ import { loadConfig } from "./config.js";
11
+ import type { ComponentMap } from "./define.js";
12
+ import { formatError } from "./format-error.js";
13
+ import { nonEmpty } from "./guards.js";
14
+ import { htmlDocument } from "./html.js";
15
+ import { buildHydrateScript } from "./hydrate.js";
16
+ import { loadUserModule, resolveModuleEntry } from "./load-user-module.js";
17
+ import { mdxToHtml } from "./mdx.js";
18
+ import { pkgRoot } from "./paths.js";
19
+ import type { CssSource } from "./tailwind.js";
20
+ import { buildCss } from "./tailwind.js";
21
+ import { builtinComponents } from "./ui/index.js";
22
+ import { PlanHeader } from "./ui/plan.js";
23
+ import { isStatus, STATUSES } from "./ui/status-badge.js";
24
+
25
+ export interface LoadedComponents {
26
+ components: ComponentMap;
27
+ code?: string;
28
+ }
29
+
30
+ /** Load a user components module (file or directory with an index file). */
31
+ export const loadComponents = async (
32
+ componentsPath: string
33
+ ): Promise<LoadedComponents> => {
34
+ const entry = resolveModuleEntry(componentsPath);
35
+ const { module: mod, code } = await loadUserModule(entry);
36
+ return { code, components: mergeUserComponents(mod) };
37
+ };
38
+
39
+ /** Components named by `config.componentsPath` — an empty map when unset. */
40
+ export const loadUserComponents = async (
41
+ config: ResolvedConfig
42
+ ): Promise<LoadedComponents> =>
43
+ config.componentsPath === undefined
44
+ ? { components: {} }
45
+ : await loadComponents(config.componentsPath);
46
+
47
+ /** Read all of our own shipped JS so Tailwind can scan built-in classes.
48
+ * Memoized: package sources don't change within a process (serve rebuilds
49
+ * would otherwise rescan dist+src on every keystroke). */
50
+ let ownSourcesCache: CssSource[] | undefined;
51
+ const ownSources = async (): Promise<CssSource[]> => {
52
+ if (ownSourcesCache !== undefined) {
53
+ return ownSourcesCache;
54
+ }
55
+ const dirs = [path.join(pkgRoot, "dist"), path.join(pkgRoot, "src")];
56
+ const out: CssSource[] = [];
57
+ const walk = async (d: string): Promise<void> => {
58
+ const ents = await readdir(d, { withFileTypes: true });
59
+ await Promise.all(
60
+ ents.map(async (e) => {
61
+ const p = path.join(d, e.name);
62
+ if (e.isDirectory()) {
63
+ await walk(p);
64
+ } else if (/\.(?:js|ts|tsx)$/u.test(e.name)) {
65
+ out.push({
66
+ content: await readFile(p, "utf-8"),
67
+ extension: e.name.split(".").pop() ?? "",
68
+ });
69
+ }
70
+ })
71
+ );
72
+ };
73
+ await Promise.all(dirs.filter((d) => existsSync(d)).map(walk));
74
+ ownSourcesCache = out;
75
+ return out;
76
+ };
77
+
78
+ export interface RenderOptions {
79
+ liveReload?: boolean;
80
+ /**
81
+ * Inline a client bundle that hydrates the document (`hydrateRoot`), making
82
+ * interactive components (Tabs, Accordion, Switch, …) actually work.
83
+ * Default true; `false` emits purely static HTML.
84
+ */
85
+ hydrate?: boolean;
86
+ /**
87
+ * Files the render pulled in (theme CSS and its imports) — `mdxr serve`
88
+ * registers them as extra watch targets so edits outside the document's
89
+ * own directory still trigger a rebuild.
90
+ */
91
+ onDependencies?: (paths: string[]) => void;
92
+ }
93
+
94
+ export interface RenderSourceOptions extends RenderOptions {
95
+ /**
96
+ * Project directory: where `mdxr.config.ts` is looked up and where relative
97
+ * paths (e.g. `<CodeFile path="…">`) resolve. Defaults to the cwd.
98
+ */
99
+ dir?: string;
100
+ /**
101
+ * Document path used in error messages and as the base for relative paths.
102
+ * Defaults to `<dir>/document.mdx`.
103
+ */
104
+ filePath?: string;
105
+ }
106
+
107
+ /**
108
+ * Inline client bundle for hydration. No catalog component was read → nothing
109
+ * in the document can hydrate, so the bundle is skipped entirely (pure
110
+ * markdown docs stay lean). A bundle failure degrades to the (correct)
111
+ * static output with a warning.
112
+ */
113
+ const buildHydrateBundle = async (args: {
114
+ code: string;
115
+ config: ResolvedConfig;
116
+ fileLinks: Record<string, string>;
117
+ headerProps?: Record<string, string | undefined>;
118
+ hydrate?: boolean;
119
+ now: string;
120
+ usedComponents: string[];
121
+ usedIcons: string[];
122
+ }): Promise<string | undefined> => {
123
+ if (!(args.hydrate ?? true) || args.usedComponents.length === 0) {
124
+ return undefined;
125
+ }
126
+ try {
127
+ return await buildHydrateScript({
128
+ code: args.code,
129
+ componentsPath:
130
+ args.config.componentsPath === undefined
131
+ ? undefined
132
+ : resolveModuleEntry(args.config.componentsPath),
133
+ fileLinks: args.fileLinks,
134
+ header: args.headerProps,
135
+ now: args.now,
136
+ usedComponents: args.usedComponents,
137
+ usedIcons: args.usedIcons,
138
+ });
139
+ } catch (error) {
140
+ process.stderr.write(
141
+ `mdxr: hydration bundle skipped: ${formatError(error)}\n`
142
+ );
143
+ return undefined;
144
+ }
145
+ };
146
+
147
+ /**
148
+ * Frontmatter `status` is free-form YAML, not a JSX prop — normalize case
149
+ * ("Doing" → "doing") and warn+drop anything outside STATUSES so a metadata
150
+ * typo can't take down the whole document render.
151
+ */
152
+ const headerStatus = (raw: string | undefined): string | undefined => {
153
+ if (raw === undefined) {
154
+ return undefined;
155
+ }
156
+ const norm = raw.trim().toLowerCase();
157
+ if (isStatus(norm)) {
158
+ return norm;
159
+ }
160
+ process.stderr.write(
161
+ `mdxr: warning: frontmatter status "${raw}" is not one of ${STATUSES.join("|")} — the badge is skipped\n`
162
+ );
163
+ return undefined;
164
+ };
165
+
166
+ /**
167
+ * PlanHeader props from frontmatter — present only with a `title` and no
168
+ * `<Plan>`-style `<article>` root in the body (that would render a second
169
+ * header). Used for both the SSR header and the hydration payload.
170
+ */
171
+ const frontmatterHeader = (
172
+ fm: (key: string) => string | undefined,
173
+ body: string
174
+ ): Record<string, string | undefined> | undefined => {
175
+ const title = fm("title");
176
+ if (title === undefined || title === "" || /<article/u.test(body)) {
177
+ return undefined;
178
+ }
179
+ return {
180
+ date: fm("date"),
181
+ owner: fm("owner"),
182
+ status: headerStatus(fm("status")),
183
+ title,
184
+ updated: fm("updated"),
185
+ version: fm("version"),
186
+ };
187
+ };
188
+
189
+ /** Render MDX source text to a standalone HTML document. */
190
+ export const render = async (
191
+ source: string,
192
+ opts: RenderSourceOptions = {}
193
+ ): Promise<string> => {
194
+ const dir = path.resolve(opts.dir ?? process.cwd());
195
+ // Relative filePaths anchor to `dir`, not cwd — mdxToHtml resolves
196
+ // `<CodeFile>`/`file:` links against file.dirname, and `dir` is the
197
+ // documented base for those (e.g. `mdxr < doc.mdx` with -d).
198
+ const filePath = path.resolve(dir, opts.filePath ?? "document.mdx");
199
+ const config: ResolvedConfig = await loadConfig(dir);
200
+
201
+ const user = await loadUserComponents(config);
202
+
203
+ // hasOwn, not `in`: prototype names ("toString", "constructor") are not
204
+ // catalog collisions.
205
+ const collisions = Object.keys(user.components).filter((k) =>
206
+ Object.hasOwn(builtinComponents, k)
207
+ );
208
+ for (const k of collisions) {
209
+ process.stderr.write(
210
+ `mdxr: project component <${k}> overrides the built-in\n`
211
+ );
212
+ }
213
+
214
+ const components: ComponentMap = {
215
+ ...builtinComponents,
216
+ ...user.components,
217
+ };
218
+
219
+ const {
220
+ body,
221
+ code,
222
+ fileLinks,
223
+ frontmatter,
224
+ renderedAt,
225
+ renderWithHeader,
226
+ usedComponents,
227
+ usedIcons,
228
+ } = await mdxToHtml(source, components, filePath, {
229
+ editor: config.editor,
230
+ hydrate: opts.hydrate,
231
+ });
232
+
233
+ const fmStr = (key: string): string | undefined => {
234
+ const val: unknown = frontmatter[key];
235
+ if (typeof val === "string") {
236
+ return val === "" ? undefined : val;
237
+ }
238
+ // YAML parses `date: 2026-09-16` into a Date.
239
+ if (val instanceof Date) {
240
+ return val.toISOString().slice(0, 10);
241
+ }
242
+ return typeof val === "number" ? String(val) : undefined;
243
+ };
244
+
245
+ const fmTitle = fmStr("title");
246
+ // `<h1>` may hold inline elements — take the full inner HTML, drop the
247
+ // tags, and undo the entities React emitted (escapeHtml re-encodes).
248
+ const h1Text = /<h1[^>]*>(?<text>[\s\S]*?)<\/h1>/u
249
+ .exec(body)
250
+ ?.groups?.text.replaceAll(/<[^>]*>/gu, "")
251
+ .replaceAll("&#x27;", "'")
252
+ .replaceAll("&quot;", '"')
253
+ .replaceAll("&lt;", "<")
254
+ .replaceAll("&gt;", ">")
255
+ .replaceAll("&amp;", "&")
256
+ .trim();
257
+ const title =
258
+ fmTitle ?? (nonEmpty(h1Text) ? h1Text : undefined) ?? "mdxr document";
259
+
260
+ const headerProps = frontmatterHeader(fmStr, body);
261
+ // The header must be part of the body's vnode tree, not concatenated HTML:
262
+ // the hydration client mounts Provider > Fragment > [header|null, doc] and
263
+ // useId() encodes tree position — separately rendered markup would shift
264
+ // every id/name/htmlFor hydration compares.
265
+ let docBody = body;
266
+ let docFileLinks = fileLinks;
267
+ let docIcons = usedIcons;
268
+ if (headerProps !== undefined) {
269
+ const pass = renderWithHeader(createElement(PlanHeader, headerProps));
270
+ docBody = pass.html;
271
+ docFileLinks = pass.fileLinks;
272
+ docIcons = pass.usedIcons;
273
+ }
274
+
275
+ // Read once for the candidate scan; the build imports the theme by path so
276
+ // relative `@import`s inside it resolve against the theme's own directory.
277
+ const themeCss =
278
+ config.themePath === undefined
279
+ ? undefined
280
+ : await readFile(config.themePath, "utf-8");
281
+
282
+ // Every class that made it into the rendered output is a candidate;
283
+ // scanning the body covers both built-in and user components.
284
+ const sources: CssSource[] = [
285
+ { content: docBody, extension: "html" },
286
+ { content: source, extension: "mdx" },
287
+ { content: themeCss ?? "", extension: "css" },
288
+ ...(await ownSources()),
289
+ ...(user.code === undefined
290
+ ? []
291
+ : [{ content: user.code, extension: "js" }]),
292
+ ...(config.componentsCode === undefined
293
+ ? []
294
+ : [{ content: config.componentsCode, extension: "js" }]),
295
+ ];
296
+ const { css, dependencies } = await buildCss(sources, config.themePath);
297
+ opts.onDependencies?.(dependencies);
298
+
299
+ const [js, hydrateJs] = await Promise.all([
300
+ clientJs(),
301
+ buildHydrateBundle({
302
+ code,
303
+ config,
304
+ fileLinks: docFileLinks,
305
+ headerProps,
306
+ hydrate: opts.hydrate,
307
+ now: renderedAt,
308
+ usedComponents,
309
+ usedIcons: docIcons,
310
+ }),
311
+ ]);
312
+
313
+ return htmlDocument({
314
+ body: docBody,
315
+ clientJs: js,
316
+ css,
317
+ hydrateJs,
318
+ liveReload: opts.liveReload,
319
+ needsKatex: /class="[^"]*katex/u.test(docBody),
320
+ needsMermaid: /class="[^"]*mermaid/u.test(docBody),
321
+ title,
322
+ });
323
+ };
324
+
325
+ /** Read `mdxPath` and render it to a standalone HTML document. */
326
+ export const renderFile = async (
327
+ mdxPath: string,
328
+ opts: RenderOptions = {}
329
+ ): Promise<string> => {
330
+ const abs = path.resolve(mdxPath);
331
+ const source = await readFile(abs, "utf-8");
332
+ return await render(source, {
333
+ ...opts,
334
+ dir: path.dirname(abs),
335
+ filePath: abs,
336
+ });
337
+ };
package/src/serve.ts ADDED
@@ -0,0 +1,322 @@
1
+ import { once } from "node:events";
2
+ import fs from "node:fs";
3
+ import http from "node:http";
4
+ import path from "node:path";
5
+
6
+ import { formatError } from "./format-error.js";
7
+ import type { RenderSourceOptions } from "./render.js";
8
+ import { render, renderFile } from "./render.js";
9
+
10
+ const errorPage = (err: unknown): string =>
11
+ `<!doctype html><meta charset="utf-8"><body style="font-family:monospace;background:#1c1917;color:#fca5a5;padding:2rem"><h1>mdxr render error</h1><pre>${formatError(err).replaceAll("&", "&amp;").replaceAll("<", "&lt;")}</pre></body>`;
12
+
13
+ interface PreviewTarget {
14
+ /** Label shown in the startup log. */
15
+ label: string;
16
+ /** Re-render the document; errors are served as an error page. */
17
+ renderDoc: () => Promise<string>;
18
+ /** Dependency files the last render pulled in (theme CSS + its imports). */
19
+ deps?: () => string[];
20
+ /** Directory watched for changes (config, components, theme, sources). */
21
+ watchDir: string;
22
+ /** Non-recursive fallback watch target (the document file). */
23
+ watchFile?: string;
24
+ }
25
+
26
+ /** Dependency/build output dirs — never worth a watch fd. */
27
+ const SKIP_DIRS = new Set([
28
+ ".git",
29
+ ".mdxr-cache",
30
+ "dist",
31
+ "node_modules",
32
+ "storybook-static",
33
+ ]);
34
+
35
+ /**
36
+ * The set of directories being watched. `fs.watch` recursive mode exists only
37
+ * on darwin/win32 — elsewhere `arm` puts one non-recursive watcher per
38
+ * directory, which also survives atomic saves (rename-over kills a watch
39
+ * aimed at the file itself).
40
+ */
41
+ const createWatchSet = () => {
42
+ const watchers: fs.FSWatcher[] = [];
43
+ const armed = new Set<string>();
44
+
45
+ const track = (w: fs.FSWatcher, dir?: string): void => {
46
+ watchers.push(w);
47
+ if (dir !== undefined) {
48
+ armed.add(dir);
49
+ // A deleted dir kills its watcher — un-arm on close so a recreated
50
+ // dir is picked up again by the next event's re-scan.
51
+ w.on("close", () => {
52
+ armed.delete(dir);
53
+ });
54
+ }
55
+ w.on("error", () => {
56
+ w.close();
57
+ });
58
+ };
59
+
60
+ /** One watcher on `dir` itself (no descent). */
61
+ const armFlat = (dir: string, onEvent: () => void): void => {
62
+ if (armed.has(dir) || SKIP_DIRS.has(path.basename(dir))) {
63
+ return;
64
+ }
65
+ try {
66
+ track(fs.watch(dir, onEvent), dir);
67
+ } catch {
68
+ // Directory gone or unwatched — skip.
69
+ }
70
+ };
71
+
72
+ /**
73
+ * Recursive fallback: a watcher on `dir` plus every directory under it.
74
+ * The `armed` check guards only the watcher install — the descent still
75
+ * runs on an already-armed root, so directories created mid-session get
76
+ * picked up on the next rebuild (armDeps re-arms the tree for this).
77
+ */
78
+ const arm = (dir: string, onEvent: () => void): void => {
79
+ if (SKIP_DIRS.has(path.basename(dir))) {
80
+ return;
81
+ }
82
+ if (!armed.has(dir)) {
83
+ try {
84
+ track(fs.watch(dir, onEvent), dir);
85
+ } catch {
86
+ // Watch failed (dir gone, fd limit) — children may still be
87
+ // watchable, so keep descending.
88
+ }
89
+ }
90
+ let ents: fs.Dirent[];
91
+ try {
92
+ ents = fs.readdirSync(dir, { withFileTypes: true });
93
+ } catch {
94
+ return;
95
+ }
96
+ for (const e of ents) {
97
+ if (e.isDirectory()) {
98
+ arm(path.join(dir, e.name), onEvent);
99
+ }
100
+ }
101
+ };
102
+
103
+ return {
104
+ arm,
105
+ armFlat,
106
+ close(): void {
107
+ for (const w of watchers) {
108
+ w.close();
109
+ }
110
+ },
111
+ get size(): number {
112
+ return watchers.length;
113
+ },
114
+ track,
115
+ };
116
+ };
117
+
118
+ const servePreview = async (
119
+ target: PreviewTarget,
120
+ port: number
121
+ ): Promise<http.Server> => {
122
+ let html = "";
123
+ const clients = new Set<http.ServerResponse>();
124
+ const server = http.createServer((req, res) => {
125
+ if (req.url === "/__mdxr_events") {
126
+ res.writeHead(200, {
127
+ "cache-control": "no-cache",
128
+ connection: "keep-alive",
129
+ "content-type": "text/event-stream",
130
+ });
131
+ res.write("retry: 1000\n\n");
132
+ clients.add(res);
133
+ req.on("close", () => {
134
+ clients.delete(res);
135
+ });
136
+ res.on("error", () => {
137
+ clients.delete(res);
138
+ });
139
+ return;
140
+ }
141
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
142
+ res.end(html);
143
+ });
144
+
145
+ const watch = createWatchSet();
146
+ let recursiveWatch = false;
147
+ let timer: NodeJS.Timeout | undefined;
148
+
149
+ /**
150
+ * Dependency files (theme CSS, its nested imports) may live outside the
151
+ * watched document directory. Inside the watch dir the root watcher covers
152
+ * them; outside, a flat watcher on the file's own directory suffices —
153
+ * and avoids descending into a potentially huge ancestor tree. Also
154
+ * re-arms the tree so directories created mid-session are picked up.
155
+ */
156
+ const armDeps = (onEvent: () => void): void => {
157
+ if (!recursiveWatch) {
158
+ watch.arm(target.watchDir, onEvent);
159
+ }
160
+ for (const dep of target.deps?.() ?? []) {
161
+ const dir = path.dirname(dep);
162
+ const inside =
163
+ dir === target.watchDir ||
164
+ dir.startsWith(`${target.watchDir}${path.sep}`);
165
+ if (inside || dir.split(path.sep).includes("node_modules")) {
166
+ continue;
167
+ }
168
+ watch.armFlat(dir, onEvent);
169
+ }
170
+ };
171
+
172
+ const rebuild = async (onEvent: () => void): Promise<void> => {
173
+ try {
174
+ html = await target.renderDoc();
175
+ } catch (error) {
176
+ html = errorPage(error);
177
+ }
178
+ armDeps(onEvent);
179
+ };
180
+
181
+ // Rebuilds chain onto each other: a change burst during a slow rebuild
182
+ // can't interleave two renders or serve an older result last. Every link
183
+ // swallows its own errors, so the stored tail can never reject — nothing
184
+ // awaits it, and an unhandled rejection would take the server down.
185
+ let reloading: Promise<void> = Promise.resolve();
186
+ const reload = (onEvent: () => void): void => {
187
+ const prev = reloading;
188
+ reloading = (async () => {
189
+ await prev;
190
+ try {
191
+ await rebuild(onEvent);
192
+ for (const c of clients) {
193
+ try {
194
+ c.write("event: reload\ndata: {}\n\n");
195
+ } catch {
196
+ clients.delete(c);
197
+ }
198
+ }
199
+ } catch {
200
+ // Notified clients are best-effort; rebuild failures are already
201
+ // rendered into the error page by rebuild() itself.
202
+ }
203
+ })();
204
+ };
205
+
206
+ const notify = (): void => {
207
+ clearTimeout(timer);
208
+ timer = setTimeout(() => {
209
+ reload(notify);
210
+ }, 80);
211
+ };
212
+
213
+ // fs.watch recursive mode exists only on darwin/win32; elsewhere `arm`
214
+ // installs the per-directory fallback.
215
+ try {
216
+ watch.track(fs.watch(target.watchDir, { recursive: true }, notify));
217
+ recursiveWatch = true;
218
+ } catch {
219
+ watch.arm(target.watchDir, notify);
220
+ }
221
+ if (watch.size === 0 && target.watchFile !== undefined) {
222
+ try {
223
+ watch.track(fs.watch(target.watchFile, notify));
224
+ } catch {
225
+ // Live reload just won't fire; the server still serves the document.
226
+ }
227
+ }
228
+ server.on("close", () => {
229
+ clearTimeout(timer);
230
+ watch.close();
231
+ });
232
+
233
+ await rebuild(notify);
234
+
235
+ // Bind loopback only — the startup log says localhost, and a preview
236
+ // server has no auth: listening on 0.0.0.0 would expose the document
237
+ // (and its file links) to the LAN.
238
+ server.listen(port, "127.0.0.1");
239
+ try {
240
+ // Rejects on 'error' (e.g. EADDRINUSE) before 'listening'.
241
+ await once(server, "listening");
242
+ } catch (error) {
243
+ // Close fires the 'close' handler above — without it a failed listen
244
+ // would leave the watchers armed and keep the process alive.
245
+ server.close();
246
+ throw error;
247
+ }
248
+
249
+ const address = server.address();
250
+ const boundPort =
251
+ typeof address === "object" && address !== null ? address.port : port;
252
+ console.log(`mdxr: serving ${target.label} at http://localhost:${boundPort}`);
253
+ console.log("mdxr: watching for changes (Ctrl+C to stop)");
254
+ return server;
255
+ };
256
+
257
+ /**
258
+ * Wraps a render fn with dependency tracking: `deps` reads the paths the
259
+ * last render reported via `onDependencies` (theme CSS + its imports), so
260
+ * `armDeps` can watch files outside the document's own directory.
261
+ */
262
+ const trackDeps = (
263
+ renderDoc: (onDeps: (paths: string[]) => void) => Promise<string>
264
+ ): Pick<PreviewTarget, "deps" | "renderDoc"> => {
265
+ let deps: string[] = [];
266
+ return {
267
+ deps: () => deps,
268
+ renderDoc: async () => {
269
+ const collected: string[] = [];
270
+ const doc = await renderDoc((d) => {
271
+ collected.push(...d);
272
+ });
273
+ deps = collected;
274
+ return doc;
275
+ },
276
+ };
277
+ };
278
+
279
+ /** Serve an .mdx file, rebuilding + live-reloading on changes in its directory. */
280
+ export const serve = async (
281
+ mdxPath: string,
282
+ port: number
283
+ ): Promise<http.Server> => {
284
+ const abs = path.resolve(mdxPath);
285
+ return await servePreview(
286
+ {
287
+ label: mdxPath,
288
+ ...trackDeps(
289
+ async (onDeps) =>
290
+ await renderFile(abs, { liveReload: true, onDependencies: onDeps })
291
+ ),
292
+ watchDir: path.dirname(abs),
293
+ watchFile: abs,
294
+ },
295
+ port
296
+ );
297
+ };
298
+
299
+ /** Serve MDX source passed directly (e.g. piped via stdin). */
300
+ export const serveSource = async (
301
+ source: string,
302
+ port: number,
303
+ opts: Omit<RenderSourceOptions, "liveReload" | "onDependencies"> = {}
304
+ ): Promise<http.Server> => {
305
+ const dir = path.resolve(opts.dir ?? process.cwd());
306
+ return await servePreview(
307
+ {
308
+ label: opts.filePath ?? "stdin",
309
+ ...trackDeps(
310
+ async (onDeps) =>
311
+ await render(source, {
312
+ dir,
313
+ filePath: opts.filePath,
314
+ liveReload: true,
315
+ onDependencies: onDeps,
316
+ })
317
+ ),
318
+ watchDir: dir,
319
+ },
320
+ port
321
+ );
322
+ };