@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
@@ -0,0 +1,533 @@
1
+ import {
2
+ transformerMetaHighlight,
3
+ transformerMetaWordHighlight,
4
+ transformerNotationDiff,
5
+ transformerNotationErrorLevel,
6
+ transformerNotationFocus,
7
+ transformerNotationHighlight,
8
+ transformerNotationWordHighlight,
9
+ transformerRemoveNotationEscape,
10
+ } from "@shikijs/transformers";
11
+ import type { Element, ElementContent, Root } from "hast";
12
+ import { createHighlighter } from "shiki";
13
+ import type { Highlighter, LanguageInput, ShikiTransformer } from "shiki";
14
+ import { visit } from "unist-util-visit";
15
+
16
+ import { isRecord, own } from "../guards.js";
17
+ import { langForPath, SKIP_LANGS } from "../langs.js";
18
+ import { fenceFilename, fenceLang } from "../lines.js";
19
+ import { parseDiff } from "../ui/diff-parse.js";
20
+ import type {
21
+ DiffHl,
22
+ DiffHlToken,
23
+ DiffRow,
24
+ FileDiff,
25
+ } from "../ui/diff-parse.js";
26
+
27
+ /**
28
+ * Fenced code blocks are syntax-highlighted with shiki at render time.
29
+ * `defaultColor: false` emits `--shiki-light`/`--shiki-dark` CSS variables on
30
+ * every token span; BASE_CSS switches between them via prefers-color-scheme.
31
+ * SKIP_LANGS (langs.ts) are left untouched: mermaid renders as a diagram,
32
+ * terminal langs as a transcript, plain-text aliases stay cheap.
33
+ */
34
+ const THEMES = { dark: "github-dark", light: "github-light" } as const;
35
+
36
+ /**
37
+ * A middle ground between startup cost and coverage: grammars in this list are
38
+ * loaded with the highlighter, anything else in shiki's bundle is loaded on
39
+ * first use, and unknown names degrade to unhighlighted code.
40
+ */
41
+ const PRELOADED_LANGS = [
42
+ "bash",
43
+ "c",
44
+ "cpp",
45
+ "csharp",
46
+ "css",
47
+ "diff",
48
+ "dockerfile",
49
+ "go",
50
+ "graphql",
51
+ "html",
52
+ "ini",
53
+ "java",
54
+ "javascript",
55
+ "json",
56
+ "jsonc",
57
+ "jsx",
58
+ "kotlin",
59
+ "lua",
60
+ "markdown",
61
+ "php",
62
+ "python",
63
+ "ruby",
64
+ "rust",
65
+ "scss",
66
+ "shell",
67
+ "sql",
68
+ "swift",
69
+ "toml",
70
+ "tsx",
71
+ "typescript",
72
+ "xml",
73
+ "yaml",
74
+ "zsh",
75
+ ] as const;
76
+
77
+ /**
78
+ * Fence-meta tokens that turn line numbers on: ` ```ts ln ` (also accepts
79
+ * `line-numbers` / `lineNumbers` / `showLineNumbers`).
80
+ */
81
+ const LINE_NUMBER_RE =
82
+ /(?:^|\s)(?:ln|line-numbers|lineNumbers|showLineNumbers)(?:\s|$)/u;
83
+
84
+ /**
85
+ * Marks shiki's `<pre>` with `has-line-numbers` when the fence meta asks for
86
+ * them; BASE_CSS draws the numbers with a counter so copy stays clean.
87
+ */
88
+ const transformerLineNumbers: ShikiTransformer = {
89
+ name: "mdxr:line-numbers",
90
+ pre(node) {
91
+ const raw = this.options.meta?.__raw ?? "";
92
+ if (LINE_NUMBER_RE.test(raw)) {
93
+ this.addClassToHast(node, "has-line-numbers");
94
+ }
95
+ },
96
+ };
97
+
98
+ /**
99
+ * Transformers shared by every highlighted block. The notation set strips
100
+ * `// [!code …]` markers from the output and tags lines/spans; the meta set
101
+ * reads the fence info string (`{1,3-5}` line ranges, `/word/` matches).
102
+ */
103
+ const TRANSFORMERS: ShikiTransformer[] = [
104
+ transformerNotationDiff(),
105
+ transformerNotationErrorLevel(),
106
+ transformerNotationFocus(),
107
+ transformerNotationHighlight(),
108
+ transformerNotationWordHighlight(),
109
+ transformerRemoveNotationEscape(),
110
+ transformerMetaHighlight(),
111
+ transformerMetaWordHighlight(),
112
+ transformerLineNumbers,
113
+ ];
114
+
115
+ let highlighterPromise: Promise<Highlighter> | undefined;
116
+
117
+ const getHighlighter = async (): Promise<Highlighter> => {
118
+ highlighterPromise ??= createHighlighter({
119
+ langs: [...PRELOADED_LANGS],
120
+ themes: [THEMES.light, THEMES.dark],
121
+ });
122
+ try {
123
+ return await highlighterPromise;
124
+ } catch (error) {
125
+ // A rejected promise would otherwise poison every later render in this
126
+ // process — clear it so a transient init failure can retry.
127
+ highlighterPromise = undefined;
128
+ throw error;
129
+ }
130
+ };
131
+
132
+ const DIFF_FENCE_LANGS: ReadonlySet<string> = new Set(["diff", "patch"]);
133
+
134
+ const isElement = (node: unknown): node is Element =>
135
+ isRecord(node) && node.type === "element" && typeof node.tagName === "string";
136
+
137
+ /** mdast-sourced nodes use `className`; shiki's hast uses `class`. Read both. */
138
+ const classNames = (el: Element | undefined): string[] => {
139
+ const c = el?.properties?.className ?? el?.properties?.class;
140
+ if (typeof c === "string") {
141
+ return c.split(/\s+/u).filter(Boolean);
142
+ }
143
+ return Array.isArray(c) ? c.map(String) : [];
144
+ };
145
+
146
+ const textContent = (node: Element | ElementContent): string => {
147
+ if (node.type === "text") {
148
+ return node.value;
149
+ }
150
+ return "children" in node ? node.children.map(textContent).join("") : "";
151
+ };
152
+
153
+ const languageOf = (code: Element): string | undefined =>
154
+ /language-(?<lang>[^\s]+)/u.exec(classNames(code).join(" "))?.groups?.lang;
155
+
156
+ /** In-flight grammar loads, deduplicated across concurrent code blocks. */
157
+ const langLoads = new Map<string, Promise<boolean>>();
158
+
159
+ /** Lazily loads a grammar; resolves false when shiki doesn't know the name. */
160
+ const ensureLanguage = async (
161
+ highlighter: Highlighter,
162
+ lang: string
163
+ ): Promise<boolean> => {
164
+ if (highlighter.getLoadedLanguages().includes(lang)) {
165
+ return true;
166
+ }
167
+ const pending = langLoads.get(lang);
168
+ if (pending !== undefined) {
169
+ return await pending;
170
+ }
171
+ const load = (async () => {
172
+ const bundled: Record<string, LanguageInput> =
173
+ highlighter.getBundledLanguages();
174
+ // own-property lookup: a fence like ```toString would otherwise hand
175
+ // Object.prototype.toString to loadLanguage.
176
+ const loader = own(bundled, lang);
177
+ if (loader === undefined) {
178
+ return false;
179
+ }
180
+ await highlighter.loadLanguage(loader);
181
+ return highlighter.getLoadedLanguages().includes(lang);
182
+ })();
183
+ langLoads.set(lang, load);
184
+ try {
185
+ return await load;
186
+ } finally {
187
+ langLoads.delete(lang);
188
+ }
189
+ };
190
+
191
+ /**
192
+ * The highlighter when `lang` can be handled — undefined for SKIP_LANGS and
193
+ * names shiki doesn't know, so callers degrade to unhighlighted code.
194
+ */
195
+ const readyHighlighter = async (
196
+ lang: string
197
+ ): Promise<Highlighter | undefined> => {
198
+ if (SKIP_LANGS.has(lang)) {
199
+ return undefined;
200
+ }
201
+ try {
202
+ const highlighter = await getHighlighter();
203
+ return (await ensureLanguage(highlighter, lang)) ? highlighter : undefined;
204
+ } catch {
205
+ // Highlighter init/grammar-load failure degrades to unhighlighted code
206
+ // rather than failing the whole document render.
207
+ return undefined;
208
+ }
209
+ };
210
+
211
+ /**
212
+ * Highlight `text` as `lang`, returning the inner HTML of shiki's `<code>`
213
+ * element — the same token spans rehypeShiki grafts onto the `code` node.
214
+ * Used by the Storybook preview, where component stories bypass the rehype
215
+ * pipeline; kept here so both paths share the highlighter and themes.
216
+ */
217
+ export const highlightToHtml = async (
218
+ lang: string,
219
+ text: string
220
+ ): Promise<string | undefined> => {
221
+ const highlighter = await readyHighlighter(lang);
222
+ if (highlighter === undefined) {
223
+ return undefined;
224
+ }
225
+ const html = highlighter.codeToHtml(text.replace(/\n$/u, ""), {
226
+ defaultColor: false,
227
+ lang,
228
+ themes: THEMES,
229
+ transformers: TRANSFORMERS,
230
+ });
231
+ return /<code[^>]*>(?<inner>[\s\S]*?)<\/code>/u.exec(html)?.groups?.inner;
232
+ };
233
+
234
+ /**
235
+ * One side of a hunk, highlighted as the file's own language: `ctx`/`add`
236
+ * rows read from the new side, `ctx`/`del` rows from the old side. Shiki's
237
+ * output line array aligns 1:1 with the joined row texts (rows never
238
+ * contain `\n`).
239
+ */
240
+ const sideTokens = (
241
+ highlighter: Highlighter,
242
+ lang: string,
243
+ src: string
244
+ ): DiffHlToken[][] => {
245
+ if (src === "") {
246
+ return [];
247
+ }
248
+ const hast = highlighter.codeToHast(src, {
249
+ defaultColor: false,
250
+ lang,
251
+ themes: THEMES,
252
+ });
253
+ const pre = hast.children.find(
254
+ (child): child is Element => isElement(child) && child.tagName === "pre"
255
+ );
256
+ const code = pre?.children.find(
257
+ (child): child is Element => isElement(child) && child.tagName === "code"
258
+ );
259
+ if (code === undefined) {
260
+ return [];
261
+ }
262
+ const lines: DiffHlToken[][] = [];
263
+ for (const child of code.children) {
264
+ if (
265
+ !isElement(child) ||
266
+ child.tagName !== "span" ||
267
+ !classNames(child).includes("line")
268
+ ) {
269
+ continue;
270
+ }
271
+ lines.push(
272
+ child.children.flatMap((tok): DiffHlToken[] => {
273
+ if (tok.type === "text") {
274
+ return tok.value === "" ? [] : [{ t: tok.value }];
275
+ }
276
+ if (!isElement(tok)) {
277
+ return [];
278
+ }
279
+ const style = tok.properties?.style;
280
+ return [
281
+ {
282
+ s: typeof style === "string" && style !== "" ? style : undefined,
283
+ t: textContent(tok),
284
+ },
285
+ ];
286
+ })
287
+ );
288
+ }
289
+ return lines;
290
+ };
291
+
292
+ /** A resolved (highlighter, lang) pair for one side of a file diff. */
293
+ interface DiffSide {
294
+ highlighter: Highlighter;
295
+ lang: string;
296
+ }
297
+
298
+ /** Token lines per row of one hunk — `null` for note rows and for rows whose
299
+ * side had no usable grammar. Add/ctx rows read the new-side highlight, del
300
+ * rows the old-side one (the sides can carry different languages after a
301
+ * cross-extension rename). */
302
+ const hunkTokens = (
303
+ sides: { new?: DiffSide; old?: DiffSide },
304
+ rows: DiffRow[]
305
+ ): (DiffHlToken[] | null)[] => {
306
+ const nt =
307
+ sides.new === undefined
308
+ ? []
309
+ : sideTokens(
310
+ sides.new.highlighter,
311
+ sides.new.lang,
312
+ rows
313
+ .filter((r) => r.kind === "ctx" || r.kind === "add")
314
+ .map((r) => r.text)
315
+ .join("\n")
316
+ );
317
+ const ot =
318
+ sides.old === undefined
319
+ ? []
320
+ : sideTokens(
321
+ sides.old.highlighter,
322
+ sides.old.lang,
323
+ rows
324
+ .filter((r) => r.kind === "ctx" || r.kind === "del")
325
+ .map((r) => r.text)
326
+ .join("\n")
327
+ );
328
+ let ni = 0;
329
+ let oi = 0;
330
+ return rows.map((row) => {
331
+ if (row.kind === "add") {
332
+ const t = nt[ni] ?? null;
333
+ ni += 1;
334
+ return t;
335
+ }
336
+ if (row.kind === "del") {
337
+ const t = ot[oi] ?? null;
338
+ oi += 1;
339
+ return t;
340
+ }
341
+ if (row.kind === "ctx") {
342
+ const t = nt[ni] ?? null;
343
+ ni += 1;
344
+ oi += 1;
345
+ return t;
346
+ }
347
+ return null;
348
+ });
349
+ };
350
+
351
+ /**
352
+ * Language-highlighted rows for every file/hunk of a parsed diff. Language
353
+ * comes from the `lang=` meta override, else the file's own path (the fence's
354
+ * `title=`/`filename=` names a single-file diff). A file whose language can't
355
+ * be resolved keeps `null` rows — it renders plain, like today.
356
+ */
357
+ const diffHighlight = async (
358
+ files: FileDiff[],
359
+ meta: string
360
+ ): Promise<DiffHl | undefined> => {
361
+ const override = fenceLang(meta);
362
+ const named = files.length === 1 ? fenceFilename(meta) : undefined;
363
+ const langOf = (path: string | undefined): string | undefined =>
364
+ override ?? (path === undefined ? undefined : langForPath(path));
365
+ const perFile = files.map((file) => ({
366
+ file,
367
+ newLang: langOf(file.newPath ?? file.oldPath ?? named),
368
+ oldLang: langOf(file.oldPath ?? file.newPath ?? named),
369
+ }));
370
+ // One parallel grammar-load pass for every language the diff touches.
371
+ const wanted = new Set(
372
+ perFile.flatMap((l) =>
373
+ [l.newLang, l.oldLang].filter((x): x is string => x !== undefined)
374
+ )
375
+ );
376
+ const ready = new Map<string, Highlighter>();
377
+ await Promise.all(
378
+ [...wanted].map(async (lang) => {
379
+ const highlighter = await readyHighlighter(lang);
380
+ if (highlighter !== undefined) {
381
+ ready.set(lang, highlighter);
382
+ }
383
+ })
384
+ );
385
+ const sideOf = (lang: string | undefined): DiffSide | undefined => {
386
+ if (lang === undefined) {
387
+ return undefined;
388
+ }
389
+ const highlighter = ready.get(lang);
390
+ return highlighter === undefined ? undefined : { highlighter, lang };
391
+ };
392
+ let any = false;
393
+ const hl = perFile.map(({ file, newLang, oldLang }): DiffHl[number] => {
394
+ const newSide = sideOf(newLang);
395
+ // Deleted rows prefer the old side's language; when it didn't resolve
396
+ // they share the new side's rather than going plain.
397
+ const oldSide = sideOf(oldLang) ?? newSide;
398
+ return file.hunks.map((h) => {
399
+ try {
400
+ const rows = hunkTokens({ new: newSide, old: oldSide }, h.rows);
401
+ any ||= rows.some((r) => r !== null);
402
+ return rows;
403
+ } catch {
404
+ // One broken grammar must not fail the whole document — this hunk's
405
+ // rows degrade to plain text.
406
+ return h.rows.map((): null => null);
407
+ }
408
+ });
409
+ });
410
+ return any ? hl : undefined;
411
+ };
412
+
413
+ /**
414
+ * The `data-diffhl` payload for a raw diff text — the Storybook counterpart of
415
+ * the rehype path, which component stories bypass (like `highlightToHtml` for
416
+ * ordinary fences). Undefined when the text doesn't parse as a structured diff
417
+ * or no row could be highlighted.
418
+ */
419
+ export const diffHighlightJson = async (
420
+ text: string,
421
+ meta: string
422
+ ): Promise<string | undefined> => {
423
+ const files = parseDiff(text).filter((f) => f.raw.length > 0);
424
+ if (files.length === 0) {
425
+ return undefined;
426
+ }
427
+ const hl = await diffHighlight(files, meta);
428
+ return hl === undefined ? undefined : JSON.stringify(hl);
429
+ };
430
+
431
+ /**
432
+ * Rehype plugin: replace the text inside `pre > code` with shiki's highlighted
433
+ * line spans. The `pre`/`code` elements and their properties (language class,
434
+ * `meta` from remarkCodeMeta) are kept so the `Pre` component's filename
435
+ * header, copy payload, and mermaid handling keep working. ```diff/```patch
436
+ * fences skip the generic path: `DiffView` replaces their markup, so instead
437
+ * each hunk's old/new sides are highlighted in the file's own language and
438
+ * parked as JSON on `code`'s `data-diffhl` prop — it serializes into the
439
+ * compiled module, reaching SSR and hydration renders identically.
440
+ */
441
+ export const rehypeShiki = () => async (tree: Root) => {
442
+ const targets: { code: Element; lang: string; meta: string; text: string }[] =
443
+ [];
444
+ const diffs: {
445
+ code: Element;
446
+ files: FileDiff[];
447
+ meta: string;
448
+ }[] = [];
449
+
450
+ visit(tree, "element", (node) => {
451
+ if (node.tagName !== "pre") {
452
+ return;
453
+ }
454
+ const code: ElementContent | undefined = node.children[0];
455
+ if (!isElement(code) || code.tagName !== "code") {
456
+ return;
457
+ }
458
+ const lang = languageOf(code);
459
+ if (lang === undefined || SKIP_LANGS.has(lang)) {
460
+ return;
461
+ }
462
+ const meta =
463
+ typeof code.properties?.meta === "string" ? code.properties.meta : "";
464
+ const text = textContent(code);
465
+ // A ```diff fence with parseable structure renders as DiffView — the
466
+ // language highlight replaces the generic pass. Unparseable content
467
+ // falls back to the ordinary diff-grammar highlight below (DiffView
468
+ // renders it as a plain code block too).
469
+ if (DIFF_FENCE_LANGS.has(lang)) {
470
+ const files = parseDiff(text).filter((f) => f.raw.length > 0);
471
+ if (files.length > 0) {
472
+ diffs.push({ code, files, meta });
473
+ return;
474
+ }
475
+ }
476
+ targets.push({ code, lang, meta, text });
477
+ });
478
+
479
+ if (targets.length === 0 && diffs.length === 0) {
480
+ return;
481
+ }
482
+
483
+ await Promise.all([
484
+ ...targets.map(async ({ code, lang, meta, text }) => {
485
+ const highlighter = await readyHighlighter(lang);
486
+ if (highlighter === undefined) {
487
+ return;
488
+ }
489
+ let hast: Root;
490
+ try {
491
+ // mdast adds a trailing newline to code values; keep it out of the
492
+ // highlight input or it surfaces as a stray empty line.
493
+ hast = highlighter.codeToHast(text.replace(/\n$/u, ""), {
494
+ defaultColor: false,
495
+ lang,
496
+ meta: { __raw: meta },
497
+ themes: THEMES,
498
+ transformers: TRANSFORMERS,
499
+ });
500
+ } catch {
501
+ // One broken grammar must not fail the whole document — the fence
502
+ // degrades to plain unhighlighted code.
503
+ return;
504
+ }
505
+ const shikiPre = hast.children.find(
506
+ (child): child is Element => isElement(child) && child.tagName === "pre"
507
+ );
508
+ const highlightedCode = shikiPre?.children[0];
509
+ if (!isElement(highlightedCode) || highlightedCode.tagName !== "code") {
510
+ return;
511
+ }
512
+ code.children = highlightedCode.children;
513
+ // Feature flags the transformers park on shiki's discarded <pre>
514
+ // (`has-diff`, `has-focused`, `has-line-numbers`, …) move onto our
515
+ // <code> so the CSS selectors below can see them.
516
+ const lifted = classNames(shikiPre).filter((c) => c.startsWith("has-"));
517
+ code.properties = {
518
+ ...code.properties,
519
+ className: [...classNames(code), "shiki", ...lifted],
520
+ };
521
+ }),
522
+ ...diffs.map(async ({ code, files, meta }) => {
523
+ const hl = await diffHighlight(files, meta);
524
+ if (hl === undefined) {
525
+ return;
526
+ }
527
+ code.properties = {
528
+ ...code.properties,
529
+ "data-diffhl": JSON.stringify(hl),
530
+ };
531
+ }),
532
+ ]);
533
+ };
@@ -0,0 +1,53 @@
1
+ import type { Node } from "unist";
2
+ import { visit } from "unist-util-visit";
3
+
4
+ import type { MdxTarget } from "./ast.js";
5
+ import { isParent, toMdxElement } from "./ast.js";
6
+ import { ALERT_RE, normalizeCalloutKind } from "./callouts.js";
7
+
8
+ /** A text node with a mutable string `value` — callers edit it in place. */
9
+ const isTextValue = (n: Node | undefined): n is MdxTarget & { value: string } =>
10
+ n !== undefined &&
11
+ n.type === "text" &&
12
+ "value" in n &&
13
+ typeof n.value === "string";
14
+
15
+ /**
16
+ * GitHub-style alerts become Callout components:
17
+ * > [!WARNING]
18
+ * > Be careful.
19
+ * renders identically to <Callout kind="warning">.
20
+ */
21
+ export const remarkMdxrAlerts = () => (tree: Node) => {
22
+ visit(tree, "blockquote", (node: Node) => {
23
+ if (!isParent(node)) {
24
+ return;
25
+ }
26
+ const [first] = node.children;
27
+ if (first === undefined || first.type !== "paragraph" || !isParent(first)) {
28
+ return;
29
+ }
30
+ const [text] = first.children;
31
+ if (!isTextValue(text)) {
32
+ return;
33
+ }
34
+
35
+ const m = ALERT_RE.exec(text.value);
36
+ if (m === null || m.groups === undefined) {
37
+ return;
38
+ }
39
+
40
+ const rest = text.value.slice(m[0].length);
41
+ text.value = rest;
42
+ if (rest === "") {
43
+ first.children.shift();
44
+ }
45
+ if (first.children.length === 0) {
46
+ node.children.shift();
47
+ }
48
+
49
+ toMdxElement(node, "mdxJsxFlowElement", "Callout", {
50
+ kind: normalizeCalloutKind(m.groups.kind),
51
+ });
52
+ });
53
+ };
@@ -0,0 +1,96 @@
1
+ /** AST helpers shared by the remark plugins (mdast/mdx node poking). */
2
+
3
+ import type { Node, Parent } from "unist";
4
+ import { visit } from "unist-util-visit";
5
+
6
+ import { isRecord } from "../guards.js";
7
+
8
+ /**
9
+ * A node mutated into an `mdxJsx*Element` (or read as one): `type`, `name`,
10
+ * `attributes`, and `children` are all assignable. The real
11
+ * `MdxJsxFlowElement` type is intentionally not required — these helpers
12
+ * accept any unist node mid-mutation.
13
+ */
14
+ export interface MdxTarget extends Node {
15
+ attributes?: unknown;
16
+ children?: Node[];
17
+ name?: string;
18
+ value?: unknown;
19
+ }
20
+
21
+ export const isParent = (n: Node): n is Parent =>
22
+ "children" in n && Array.isArray(n.children);
23
+
24
+ export const isFlowElement = (n: Node): n is MdxTarget =>
25
+ n.type === "mdxJsxFlowElement";
26
+
27
+ /** All literal text under `node`, concatenated in tree order — `inlineCode`
28
+ * carries its code in `value` too, and dropping it would truncate heading
29
+ * slugs and directive labels at the first backtick span. */
30
+ export const textContent = (node: Node): string => {
31
+ let out = "";
32
+ visit(node, ["text", "inlineCode"], (n: Node) => {
33
+ if ("value" in n && typeof n.value === "string") {
34
+ out += n.value;
35
+ }
36
+ });
37
+ return out;
38
+ };
39
+
40
+ /** A JSX attribute's string value, or undefined when absent/empty/non-string. */
41
+ export const jsxAttr = (node: MdxTarget, name: string): string | undefined => {
42
+ if (!Array.isArray(node.attributes)) {
43
+ return undefined;
44
+ }
45
+ for (const a of node.attributes) {
46
+ if (
47
+ isRecord(a) &&
48
+ a.name === name &&
49
+ typeof a.value === "string" &&
50
+ a.value !== ""
51
+ ) {
52
+ return a.value;
53
+ }
54
+ }
55
+ return undefined;
56
+ };
57
+
58
+ /**
59
+ * `Record` → `mdxJsxAttribute[]`. null/undefined/"" values are dropped —
60
+ * MDX attributes arrive as strings, so everything is `String()`-coerced.
61
+ */
62
+ export const jsxAttrs = (
63
+ attrs: Record<string, unknown>
64
+ ): { name: string; type: "mdxJsxAttribute"; value: string }[] =>
65
+ Object.entries(attrs)
66
+ .filter(([, val]) => val !== null && val !== undefined && val !== "")
67
+ .map(([key, val]) => ({
68
+ name: key,
69
+ type: "mdxJsxAttribute",
70
+ value: String(val),
71
+ }));
72
+
73
+ /** Mutate `node` into an `mdxJsxFlowElement`/`mdxJsxTextElement` in place. */
74
+ export const toMdxElement = (
75
+ node: MdxTarget,
76
+ kind: "mdxJsxFlowElement" | "mdxJsxTextElement",
77
+ name: string,
78
+ attrs: Record<string, unknown>
79
+ ): void => {
80
+ node.type = kind;
81
+ node.name = name;
82
+ node.attributes = jsxAttrs(attrs);
83
+ };
84
+
85
+ /**
86
+ * Merge `key: value` into `node.data.hProperties` (creating both levels).
87
+ * The mdast→hast bridge turns hProperties into element props.
88
+ */
89
+ export const setHProperty = (node: Node, key: string, value: unknown): void => {
90
+ const data: Record<string, unknown> = isRecord(node.data) ? node.data : {};
91
+ node.data = data;
92
+ data.hProperties = {
93
+ ...(isRecord(data.hProperties) ? data.hProperties : {}),
94
+ [key]: value,
95
+ };
96
+ };
@@ -0,0 +1,25 @@
1
+ /** Callout kinds shared by `:::kind` directives and `> [!KIND]` alerts. */
2
+
3
+ export const CALLOUT_KINDS = new Set([
4
+ "note",
5
+ "tip",
6
+ "important",
7
+ "warning",
8
+ "caution",
9
+ "danger",
10
+ "decision",
11
+ "goal",
12
+ "nongoal",
13
+ "question",
14
+ "answer",
15
+ ]);
16
+
17
+ /** Lowercases and maps the `non-goal`/`nongoal` spellings to `nongoal`. */
18
+ export const normalizeCalloutKind = (kind: string): string =>
19
+ kind.toLowerCase().replace(/^non-goal$/u, "nongoal");
20
+
21
+ /** GitHub alert marker: `> [!WARNING]` — kinds derived from CALLOUT_KINDS. */
22
+ export const ALERT_RE = new RegExp(
23
+ `^\\[!(?<kind>${[...CALLOUT_KINDS].map((k) => (k === "nongoal" ? "non-?goal" : k)).join("|")})\\]\\s*`,
24
+ "iu"
25
+ );