@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,544 @@
1
+ import { Fragment, isValidElement } from "react";
2
+ import type { ReactElement, ReactNode } from "react";
3
+ import * as v from "valibot";
4
+
5
+ import {
6
+ defineComponent,
7
+ flattenChildren,
8
+ parseProps,
9
+ textOf,
10
+ } from "../define.js";
11
+ import { asString, isRecord, nonEmpty } from "../guards.js";
12
+ import { fenceFilename, parseLineRange } from "../lines.js";
13
+ import {
14
+ ACTION_BUTTON_CLS,
15
+ AddCommentButton,
16
+ CommentReplyButton,
17
+ CommentStrip,
18
+ CopyFeedback,
19
+ MaybeLink,
20
+ Panel,
21
+ TrimBody,
22
+ } from "./bits.js";
23
+ import { indexChildren } from "./child-index.js";
24
+ import { isEl, propOf } from "./children.js";
25
+ import { parseDiff, parseDiffHl } from "./diff-parse.js";
26
+ import type { DiffCommentSpec } from "./diff.js";
27
+ import { DiffView } from "./diff.js";
28
+ import { useFileLink } from "./file-link.js";
29
+ import { Icon } from "./icon.js";
30
+ import { initials } from "./owner.js";
31
+ import { CodeHeader, Pre } from "./pre.js";
32
+ import { Comment } from "./review.js";
33
+ import type { SeverityLevel } from "./severity.js";
34
+ import { SEVERITY_LEVELS, Severity } from "./severity.js";
35
+ import {
36
+ BORDER_CLS,
37
+ CHIP_BORDER_CLS,
38
+ commentStripCls,
39
+ LINK_CLS,
40
+ LOC_CLS,
41
+ MINI_CHIP_CLS,
42
+ SURFACE_CLS,
43
+ TEXT,
44
+ TONE,
45
+ TRIM_CLS,
46
+ } from "./tones.js";
47
+
48
+ /**
49
+ * Line-anchored comment threads on a code or diff fence — GitHub's PR
50
+ * review shape. `<Comments>` takes one fenced code block child plus
51
+ * `<Comment>` children carrying `lines`/`side`/`file` anchors; each thread
52
+ * renders inline under the line it targets. Anchoring and file/hunk
53
+ * routing live here and in diff.tsx's `DiffCommentSpec` plumbing.
54
+ */
55
+
56
+ const SIDES = ["old", "new"] as const;
57
+ type Side = (typeof SIDES)[number];
58
+
59
+ /** `<Comment>` props as read by `<Comments>` — the standalone schema is
60
+ * `looseObject`, so anchor attributes pass through; this schema validates
61
+ * them (incl. the `lines` grammar) for the anchored context. */
62
+ const ANNOTATION_SCHEMA = v.looseObject({
63
+ author: v.optional(v.string()),
64
+ file: v.optional(v.string()),
65
+ href: v.optional(v.string()),
66
+ lines: v.optional(
67
+ v.pipe(
68
+ v.string(),
69
+ v.check(
70
+ (s) => parseLineRange(s) !== undefined,
71
+ 'expected "40", "40-52" or "40-"'
72
+ )
73
+ )
74
+ ),
75
+ severity: v.optional(v.picklist(SEVERITY_LEVELS)),
76
+ side: v.optional(v.picklist(SIDES), "new"),
77
+ title: v.optional(v.string()),
78
+ });
79
+
80
+ interface Annotation {
81
+ file?: string;
82
+ /** Raw `lines` spec — strip anchors and the markdown serializer reuse it. */
83
+ lines?: string;
84
+ /** Rendered card — injected at the anchor row. */
85
+ node: ReactNode;
86
+ range?: { end?: number; start: number };
87
+ side: Side;
88
+ }
89
+
90
+ const isLineElement = (n: ReactElement): boolean =>
91
+ /(?:^|\s)line(?:\s|$)/u.test(asString(propOf(n, "className")) ?? "");
92
+
93
+ /**
94
+ * One rendered node per source line of a `code` element. Shiki output
95
+ * already arrives as `span.line` children (separated by `\n` text nodes);
96
+ * unhighlighted fences carry raw text, split here into matching `line`
97
+ * spans so numbering/bands treat every row identically.
98
+ */
99
+ const lineNodes = (children: ReactNode): ReactNode[] => {
100
+ const lines = flattenChildren(children).filter(
101
+ (n): n is ReactElement => isValidElement(n) && isLineElement(n)
102
+ );
103
+ if (lines.length > 0) {
104
+ return lines;
105
+ }
106
+ const text = textOf(children).replace(/\n$/u, "");
107
+ return text === ""
108
+ ? []
109
+ : text.split("\n").map((t, i) => (
110
+ <span className="line" key={i}>
111
+ {t}
112
+ </span>
113
+ ));
114
+ };
115
+
116
+ /** Avatar disc in the strip's left gutter — initials for a named author,
117
+ * a thread icon otherwise (GitHub always shows the commenter's avatar). */
118
+ const Avatar = ({ author }: { author?: string }): ReactElement =>
119
+ nonEmpty(author) ? (
120
+ <span
121
+ aria-hidden
122
+ className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-300 text-[10px] font-semibold text-neutral-700 dark:bg-neutral-600 dark:text-neutral-100"
123
+ >
124
+ {initials(author)}
125
+ </span>
126
+ ) : (
127
+ <span
128
+ aria-hidden
129
+ className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
130
+ >
131
+ <Icon className="h-3.5 w-3.5" name="lucide:message-square-text" />
132
+ </span>
133
+ );
134
+
135
+ /** One GitHub-style review comment: avatar in the left gutter, then a
136
+ * bordered box whose muted header reads `name commented · title` with a
137
+ * right cluster of severity pill, `old`-side chip and the `:lines` anchor
138
+ * link. `children` is the MDX body — nested fences render as ordinary
139
+ * code panels, so ` ```diff ` suggestion blocks just work. */
140
+ const CommentCard = ({
141
+ author,
142
+ children,
143
+ file,
144
+ href,
145
+ lines,
146
+ path,
147
+ severity,
148
+ side,
149
+ text,
150
+ title,
151
+ }: {
152
+ author?: string;
153
+ children?: ReactNode;
154
+ /** Authored `file` attr (not the resolved link path) — serialized back. */
155
+ file?: string;
156
+ href?: string;
157
+ lines?: string;
158
+ path?: string;
159
+ severity?: SeverityLevel;
160
+ side?: Side;
161
+ /** Flattened body text — the markdown serializer's `<Comment>` payload. */
162
+ text?: string;
163
+ title?: string;
164
+ }): ReactElement => {
165
+ const link = useFileLink(path, lines, href);
166
+ const name = nonEmpty(author) ? author.replace(/^@+/u, "") : undefined;
167
+ // data-mdxr-comment marks the card for the markdown serializer; each
168
+ // data-comment-* attr maps back to a <Comment> attribute (doc-events.ts).
169
+ return (
170
+ <div
171
+ className="flex gap-2.5"
172
+ data-comment-author={author}
173
+ data-comment-file={file}
174
+ data-comment-href={href}
175
+ data-comment-lines={lines}
176
+ data-comment-severity={severity}
177
+ data-comment-side={side === "old" ? "old" : undefined}
178
+ data-comment-text={text}
179
+ data-comment-title={title}
180
+ data-mdxr-comment=""
181
+ >
182
+ <Avatar author={author} />
183
+ <article
184
+ className={`min-w-0 flex-1 overflow-hidden rounded-md border ${BORDER_CLS} bg-white dark:bg-neutral-950`}
185
+ >
186
+ <header
187
+ className={`flex flex-wrap items-center gap-x-1.5 gap-y-1 border-b ${BORDER_CLS} ${SURFACE_CLS} px-3 py-1.5 text-xs`}
188
+ >
189
+ {name === undefined ? null : (
190
+ <span className={`font-semibold ${TEXT.strong}`}>{name}</span>
191
+ )}
192
+ <span className={TEXT.muted}>commented</span>
193
+ {nonEmpty(title) ? (
194
+ <span className={`font-medium ${TEXT.strong}`}>· {title}</span>
195
+ ) : null}
196
+ <span className="ml-auto inline-flex items-center gap-1.5">
197
+ {severity === undefined ? null : <Severity level={severity} />}
198
+ {side === "old" ? (
199
+ <span className={`${MINI_CHIP_CLS} ${TONE.red}`}>old</span>
200
+ ) : null}
201
+ {nonEmpty(lines) ? (
202
+ <MaybeLink className={`${LOC_CLS} ${LINK_CLS}`} href={link}>
203
+ :{lines}
204
+ </MaybeLink>
205
+ ) : null}
206
+ </span>
207
+ </header>
208
+ <TrimBody className="px-3.5 py-2.5 text-sm">{children}</TrimBody>
209
+ </article>
210
+ </div>
211
+ );
212
+ };
213
+
214
+ /** Typed `props.children` off an element — `isValidElement` narrowing
215
+ * keeps this free of type assertions. */
216
+ const childrenOf = (el: unknown): ReactNode =>
217
+ isValidElement<{ children?: ReactNode }>(el) ? el.props.children : undefined;
218
+
219
+ /** Raw `<Comment>` element → annotation record with its rendered card. */
220
+ const toAnnotation = (
221
+ el: ReactElement,
222
+ i: number,
223
+ fallbackPath: string | undefined
224
+ ): Annotation => {
225
+ const p = parseProps(ANNOTATION_SCHEMA, el.props, "Comment");
226
+ const range = p.lines === undefined ? undefined : parseLineRange(p.lines);
227
+ const node = (
228
+ <CommentCard
229
+ author={p.author}
230
+ file={p.file}
231
+ href={p.href}
232
+ key={i}
233
+ lines={p.lines}
234
+ path={p.file ?? fallbackPath}
235
+ severity={p.severity}
236
+ side={p.side}
237
+ text={textOf(childrenOf(el)).trim()}
238
+ title={p.title}
239
+ >
240
+ {childrenOf(el)}
241
+ </CommentCard>
242
+ );
243
+ return { file: p.file, lines: p.lines, node, range, side: p.side };
244
+ };
245
+
246
+ /** Group comments by anchor line: a thread lands under `min(end, lines)`
247
+ * — comments starting past EOF join the file-level `tail`. */
248
+ const anchorCode = (
249
+ comments: readonly Annotation[],
250
+ lineCount: number
251
+ ): { after: ReadonlyMap<number, Annotation[]>; tail: Annotation[] } => {
252
+ const after = new Map<number, Annotation[]>();
253
+ const tail: Annotation[] = [];
254
+ for (const c of comments) {
255
+ if (c.range === undefined || c.range.start > lineCount) {
256
+ tail.push(c);
257
+ continue;
258
+ }
259
+ const end = Math.min(c.range.end ?? lineCount, lineCount);
260
+ after.set(end, [...(after.get(end) ?? []), c]);
261
+ }
262
+ return { after, tail };
263
+ };
264
+
265
+ /** The anchor replies inherit — the thread's last comment's spec. */
266
+ const stripAnchor = (
267
+ group: Annotation[] | undefined
268
+ ): { file?: string; lines?: string; side?: Side } | undefined => {
269
+ const last = group?.at(-1);
270
+ return last === undefined
271
+ ? undefined
272
+ : { file: last.file, lines: last.lines, side: last.side };
273
+ };
274
+
275
+ /** The fenced subject decomposed: `code` element attributes + raw text. */
276
+ interface SubjectBlock {
277
+ children: ReactNode;
278
+ className?: string;
279
+ diffhl?: string;
280
+ filename?: string;
281
+ lang?: string;
282
+ /** Raw fence meta — the markdown serializer reproduces it verbatim. */
283
+ meta: string;
284
+ text: string;
285
+ }
286
+
287
+ const subjectBlock = (subject: ReactElement): SubjectBlock => {
288
+ const codeEl = flattenChildren(childrenOf(subject)).find(
289
+ (n): n is ReactElement => isValidElement(n) && n.type === "code"
290
+ );
291
+ const codeProps = isRecord(codeEl?.props) ? codeEl.props : {};
292
+ const lang = /language-(?<lang>[\w-]+)/u.exec(
293
+ asString(codeProps.className) ?? ""
294
+ )?.groups?.lang;
295
+ const meta =
296
+ asString(propOf(subject, "meta")) ??
297
+ asString(codeProps.meta) ??
298
+ asString(codeProps.metastring) ??
299
+ "";
300
+ const children = childrenOf(codeEl);
301
+ return {
302
+ children,
303
+ className: asString(codeProps.className),
304
+ diffhl: asString(codeProps["data-diffhl"]),
305
+ filename: fenceFilename(meta),
306
+ lang,
307
+ meta,
308
+ text: textOf(children),
309
+ };
310
+ };
311
+
312
+ const isDiffBlock = (block: SubjectBlock): boolean =>
313
+ (block.lang === "diff" || block.lang === "patch") &&
314
+ block.text.trim() !== "" &&
315
+ parseDiff(block.text).some((f) => f.raw.length > 0);
316
+
317
+ /** Code-block subject: `.line` rows with the comment strips interleaved —
318
+ * the `.shiki` ancestor keeps token colors and the `has-line-numbers`
319
+ * counter numbers rows across strip boundaries. */
320
+ const AnnotatedCode = ({
321
+ block,
322
+ comments,
323
+ }: {
324
+ block: SubjectBlock;
325
+ comments: readonly Annotation[];
326
+ }): ReactElement => {
327
+ const lines = lineNodes(block.children);
328
+ const anchored = anchorCode(comments, lines.length);
329
+ // `language-*`/`has-*` classes off the code element keep feature flags
330
+ // (focused lines, diff markers); `shiki`/`has-line-numbers` come back
331
+ // canonically so the class list never duplicates.
332
+ const codeCls = (block.className ?? "")
333
+ .split(/\s+/u)
334
+ .filter((c) => c !== "" && c !== "shiki" && c !== "has-line-numbers")
335
+ .join(" ");
336
+ return (
337
+ <Panel>
338
+ <CodeHeader
339
+ filename={block.filename}
340
+ lang={block.lang}
341
+ text={block.text}
342
+ />
343
+ <div className="overflow-x-auto bg-white py-2 dark:bg-neutral-950">
344
+ <div
345
+ className={`${codeCls} shiki has-line-numbers px-4 font-mono text-[0.8125rem] leading-5 whitespace-pre`}
346
+ >
347
+ {lines.map((el, i) => (
348
+ <Fragment key={i}>
349
+ <div className="mdxr-cline" data-comment-row="">
350
+ <AddCommentButton line={i + 1} />
351
+ {el}
352
+ </div>
353
+ {anchored.after.get(i + 1) === undefined ? null : (
354
+ <CommentStrip
355
+ anchor={stripAnchor(anchored.after.get(i + 1))}
356
+ bleed
357
+ >
358
+ {anchored.after.get(i + 1)?.map((a): ReactNode => a.node)}
359
+ </CommentStrip>
360
+ )}
361
+ </Fragment>
362
+ ))}
363
+ {anchored.tail.length === 0 ? null : (
364
+ <CommentStrip bleed>
365
+ {anchored.tail.map((a): ReactNode => a.node)}
366
+ </CommentStrip>
367
+ )}
368
+ </div>
369
+ </div>
370
+ </Panel>
371
+ );
372
+ };
373
+
374
+ /** Field look shared by the comment form's textarea and name input. */
375
+ const FORM_CONTROL_CLS = `w-full min-w-0 rounded-lg border ${CHIP_BORDER_CLS} bg-transparent px-2.5 py-1.5 text-sm outline-none transition-colors placeholder:text-neutral-400 focus-visible:border-neutral-400 focus-visible:ring-3 focus-visible:ring-neutral-200/70 dark:placeholder:text-neutral-500 dark:focus-visible:ring-neutral-800`;
376
+
377
+ const FORM_SUBMIT_CLS = `inline-flex cursor-pointer items-center rounded-md border border-transparent bg-neutral-900 px-2.5 py-1 text-xs font-medium text-white transition-colors hover:bg-neutral-700 active:translate-y-px dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-300`;
378
+
379
+ /**
380
+ * Inert markup the client clones for interactive bits — `data-comment-tpl`
381
+ * names each fragment: `card` is the CommentCard skeleton with `data-cc-*`
382
+ * fill slots, `form` the reply/new-comment form, `strip` a thread row (its
383
+ * `bleed` variant follows the subject: code strips stretch across the px-4
384
+ * code padding, diff strips don't). Templates keep this markup out of the
385
+ * serializer's `[data-mdxr-comment]` scan and single-source the classes.
386
+ */
387
+ const CommentTemplates = ({ bleed }: { bleed: boolean }): ReactElement => (
388
+ <>
389
+ <template data-comment-tpl="card">
390
+ <div className="flex gap-2.5" data-mdxr-comment="">
391
+ <span
392
+ aria-hidden
393
+ className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
394
+ data-cc-avatar-anon=""
395
+ >
396
+ <Icon className="h-3.5 w-3.5" name="lucide:message-square-text" />
397
+ </span>
398
+ <span
399
+ aria-hidden
400
+ className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-300 text-[10px] font-semibold text-neutral-700 dark:bg-neutral-600 dark:text-neutral-100"
401
+ data-cc-avatar=""
402
+ />
403
+ <article
404
+ className={`min-w-0 flex-1 overflow-hidden rounded-md border ${BORDER_CLS} bg-white dark:bg-neutral-950`}
405
+ >
406
+ <header
407
+ className={`flex flex-wrap items-center gap-x-1.5 gap-y-1 border-b ${BORDER_CLS} ${SURFACE_CLS} px-3 py-1.5 text-xs`}
408
+ >
409
+ <span className={`font-semibold ${TEXT.strong}`} data-cc-name="" />
410
+ <span className={TEXT.muted}>commented</span>
411
+ <span className="ml-auto inline-flex items-center gap-1.5">
412
+ <span className={`${MINI_CHIP_CLS} ${TONE.red}`} data-cc-old="">
413
+ old
414
+ </span>
415
+ <span className={LOC_CLS} data-cc-lines="" />
416
+ </span>
417
+ </header>
418
+ <div
419
+ className={`${TRIM_CLS} px-3.5 py-2.5 text-sm`}
420
+ data-cc-body=""
421
+ />
422
+ </article>
423
+ </div>
424
+ </template>
425
+ <template data-comment-tpl="form">
426
+ <div
427
+ className={`rounded-md border ${BORDER_CLS} bg-white p-2.5 dark:bg-neutral-950`}
428
+ data-comment-form=""
429
+ >
430
+ <textarea
431
+ aria-label="Comment text"
432
+ className={FORM_CONTROL_CLS}
433
+ data-comment-input=""
434
+ placeholder="Write a comment"
435
+ rows={3}
436
+ />
437
+ <div className="mt-2 flex items-center gap-2">
438
+ <input
439
+ aria-label="Name (optional)"
440
+ className={`${FORM_CONTROL_CLS} w-40`}
441
+ data-comment-author=""
442
+ placeholder="Name (optional)"
443
+ type="text"
444
+ />
445
+ <span className="ml-auto flex items-center gap-1.5">
446
+ <button
447
+ className={ACTION_BUTTON_CLS}
448
+ data-comment-cancel=""
449
+ type="button"
450
+ >
451
+ Cancel
452
+ </button>
453
+ <button
454
+ className={FORM_SUBMIT_CLS}
455
+ data-comment-submit=""
456
+ type="button"
457
+ >
458
+ Comment
459
+ </button>
460
+ </span>
461
+ </div>
462
+ </div>
463
+ </template>
464
+ <template data-comment-tpl="strip">
465
+ <div className={commentStripCls(bleed)} data-comment-strip="">
466
+ <div className="mdxr-thread-tools" data-thread-tools="">
467
+ <CommentReplyButton />
468
+ </div>
469
+ </div>
470
+ </template>
471
+ </>
472
+ );
473
+
474
+ export const Comments = defineComponent(
475
+ {
476
+ description:
477
+ 'コード/diff フェンスへの行アンカーコメント(GitHub レビュー形式)。最初のフェンス子が対象。<Comment> 子は lines="40"|"40-52"|"40-" で範囲指定 — diff では side="old|new"(左右)と file=(複数ファイル時の対象選択)も解釈。author/severity/title でヘッダ装飾。lines 省略やアンカー不能なコメントは末尾にファイルレベルコメントとして表示。読者は行ホバーの + でコメント追加、各スレッドの Reply で返信でき、「Copy markdown」がコード+全コメントを <Comments> マークアップとしてコピーする(貼り戻せば永続化できる)',
478
+ schema: v.looseObject({}),
479
+ },
480
+ ({ children }) => {
481
+ const flat = flattenChildren(children);
482
+ const subject = flat.find((n) => isEl(n, Pre));
483
+ if (subject === undefined) {
484
+ // No fence to anchor to — comments still render as standalone cards.
485
+ return (
486
+ <div className="not-prose space-y-3">{indexChildren(children)}</div>
487
+ );
488
+ }
489
+ const block = subjectBlock(subject);
490
+ const annotations = flat
491
+ .filter((n) => isEl(n, Comment))
492
+ .map((el, i) => toAnnotation(el, i, block.filename));
493
+ const rest = flat.filter((n) => n !== subject && !isEl(n, Comment));
494
+ const diff = isDiffBlock(block);
495
+ const view = diff ? (
496
+ <DiffView
497
+ comments={annotations.map((a): DiffCommentSpec => ({
498
+ file: a.file,
499
+ lines: a.lines,
500
+ node: a.node,
501
+ range: a.range,
502
+ side: a.side,
503
+ }))}
504
+ filename={block.filename}
505
+ hl={parseDiffHl(block.diffhl)}
506
+ text={block.text}
507
+ />
508
+ ) : (
509
+ <AnnotatedCode block={block} comments={annotations} />
510
+ );
511
+ // data-comments* feeds the markdown serializer (doc-events.ts): the
512
+ // fence round-trips verbatim, cards carry their own <Comment> attrs.
513
+ return (
514
+ <div
515
+ data-comments=""
516
+ data-comments-code={block.text}
517
+ data-comments-lang={block.lang}
518
+ data-comments-meta={nonEmpty(block.meta) ? block.meta : undefined}
519
+ >
520
+ {view}
521
+ {rest.map((n, i) => (
522
+ <Fragment key={i}>{n}</Fragment>
523
+ ))}
524
+ <div className="not-prose mt-2 flex items-center justify-between gap-3">
525
+ <span className={`text-xs ${TEXT.faint}`}>
526
+ Hover a line or hit Reply to comment, then copy the markup.
527
+ </span>
528
+ <button
529
+ className={ACTION_BUTTON_CLS}
530
+ data-comments-copy=""
531
+ type="button"
532
+ >
533
+ <CopyFeedback
534
+ done="Copied"
535
+ icon="lucide:clipboard-list"
536
+ label="Copy markdown"
537
+ />
538
+ </button>
539
+ </div>
540
+ <CommentTemplates bleed={!diff} />
541
+ </div>
542
+ );
543
+ }
544
+ );
@@ -0,0 +1,67 @@
1
+ import type { ReactNode, ReactElement } from "react";
2
+ import * as v from "valibot";
3
+
4
+ import { defineComponent } from "../define.js";
5
+ import { TITLE_PROP } from "./attrs.js";
6
+ import { Icon } from "./icon.js";
7
+ import { TONE_BAND, TONE_BORDER, TRIM_CLS } from "./tones.js";
8
+
9
+ const panel = (
10
+ cls: { border: string; head: string },
11
+ icon: string,
12
+ label: string,
13
+ title: string | undefined,
14
+ children: ReactNode
15
+ ): ReactElement => (
16
+ <div className={`overflow-hidden rounded-lg border ${cls.border}`}>
17
+ <div
18
+ className={`flex items-center gap-1.5 border-b px-4 py-2 text-xs font-semibold ${cls.head}`}
19
+ >
20
+ <Icon className="h-3.5 w-3.5" name={icon} />
21
+ {title ?? label}
22
+ </div>
23
+ <div className={`px-4 py-2.5 ${TRIM_CLS}`}>{children}</div>
24
+ </div>
25
+ );
26
+
27
+ /**
28
+ * Before/After panels — wrap in `<Columns>` for side-by-side layout.
29
+ * `title` overrides the label (e.g. "Current" / "Proposed").
30
+ */
31
+ export const Before = defineComponent(
32
+ {
33
+ description:
34
+ "Before パネル (赤系)。<Columns> と組み合わせて before/after 比較に。title でラベル上書き可",
35
+ schema: v.looseObject(TITLE_PROP),
36
+ },
37
+ ({ title, children }) =>
38
+ panel(
39
+ {
40
+ border: TONE_BORDER.red,
41
+ head: `${TONE_BORDER.red} ${TONE_BAND.red}`,
42
+ },
43
+ "lucide:history",
44
+ "Before",
45
+ title,
46
+ children
47
+ )
48
+ );
49
+
50
+ export const After = defineComponent(
51
+ {
52
+ description:
53
+ "After パネル (緑系)。<Columns> と組み合わせて before/after 比較に。title でラベル上書き可",
54
+ schema: v.looseObject(TITLE_PROP),
55
+ },
56
+ ({ title, children }) =>
57
+ panel(
58
+ {
59
+ border: TONE_BORDER.emerald,
60
+ head: `${TONE_BORDER.emerald} ${TONE_BAND.emerald}`,
61
+ },
62
+ "lucide:sparkles",
63
+ "After",
64
+ title,
65
+ children
66
+ )
67
+ );
@@ -0,0 +1,79 @@
1
+ import * as v from "valibot";
2
+
3
+ import { defineComponent } from "../define.js";
4
+ import { nonEmpty } from "../guards.js";
5
+ import { Pill, TrimBody } from "./bits.js";
6
+ import { TEXT, TONE, TONE_BORDER } from "./tones.js";
7
+
8
+ export const DECISION_STATUSES = [
9
+ "accepted",
10
+ "deprecated",
11
+ "proposed",
12
+ "rejected",
13
+ "superseded",
14
+ ] as const;
15
+ export type DecisionStatus = (typeof DECISION_STATUSES)[number];
16
+
17
+ const STYLES: Record<
18
+ DecisionStatus,
19
+ { cls: string; icon: string; label: string }
20
+ > = {
21
+ accepted: {
22
+ cls: TONE.emerald,
23
+ icon: "lucide:check",
24
+ label: "Accepted",
25
+ },
26
+ deprecated: {
27
+ cls: TONE.neutral,
28
+ icon: "lucide:archive",
29
+ label: "Deprecated",
30
+ },
31
+ proposed: {
32
+ cls: TONE.sky,
33
+ icon: "lucide:pen-line",
34
+ label: "Proposed",
35
+ },
36
+ rejected: {
37
+ cls: TONE.red,
38
+ icon: "lucide:x",
39
+ label: "Rejected",
40
+ },
41
+ superseded: {
42
+ cls: TONE.amber,
43
+ icon: "lucide:replace",
44
+ label: "Superseded",
45
+ },
46
+ };
47
+
48
+ export const Decision = defineComponent(
49
+ {
50
+ description:
51
+ "決定記録 (ADR-lite)。status は proposed|accepted|rejected|deprecated|superseded。:::decision は軽量版の Callout",
52
+ schema: v.looseObject({
53
+ date: v.optional(v.string()),
54
+ status: v.optional(v.picklist(DECISION_STATUSES), "proposed"),
55
+ title: v.string(),
56
+ }),
57
+ },
58
+ ({ title, status, date, children }) => {
59
+ const s = STYLES[status];
60
+ return (
61
+ <section
62
+ className={`my-6 rounded-lg border bg-indigo-50/50 px-4 py-3 dark:bg-indigo-950/30 ${TONE_BORDER.indigo}`}
63
+ >
64
+ <div className="flex flex-wrap items-baseline gap-2">
65
+ <span className="font-semibold text-indigo-900 dark:text-indigo-100">
66
+ {title}
67
+ </span>
68
+ <Pill className={s.cls} icon={s.icon}>
69
+ {s.label}
70
+ </Pill>
71
+ {nonEmpty(date) ? (
72
+ <time className={`font-mono text-xs ${TEXT.muted}`}>{date}</time>
73
+ ) : null}
74
+ </div>
75
+ <TrimBody className="mt-1.5 text-sm">{children}</TrimBody>
76
+ </section>
77
+ );
78
+ }
79
+ );