@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/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("'", "'")
|
|
252
|
+
.replaceAll(""", '"')
|
|
253
|
+
.replaceAll("<", "<")
|
|
254
|
+
.replaceAll(">", ">")
|
|
255
|
+
.replaceAll("&", "&")
|
|
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("&", "&").replaceAll("<", "<")}</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
|
+
};
|