@karimsa/mdxserve 0.0.0-stage → 0.2.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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -2
  3. package/client/App.tsx +7 -0
  4. package/client/CodeBlock.tsx +395 -0
  5. package/client/CrossFade.tsx +72 -0
  6. package/client/DocContext.ts +14 -0
  7. package/client/DocView.tsx +107 -0
  8. package/client/ErrorBox.tsx +23 -0
  9. package/client/Heading.tsx +31 -0
  10. package/client/HomeEmptyState.tsx +101 -0
  11. package/client/HomeView.tsx +78 -0
  12. package/client/ListingView.tsx +663 -0
  13. package/client/MdSection.tsx +234 -0
  14. package/client/MdSectionEditor.tsx +233 -0
  15. package/client/Mermaid.tsx +435 -0
  16. package/client/RenderErrorBoundary.tsx +40 -0
  17. package/client/Table.tsx +14 -0
  18. package/client/TaskCheckbox.tsx +38 -0
  19. package/client/api.ts +138 -0
  20. package/client/app.css +372 -0
  21. package/client/builtins/Badge.tsx +109 -0
  22. package/client/builtins/Button.tsx +111 -0
  23. package/client/builtins/Callout.tsx +97 -0
  24. package/client/builtins/Card.tsx +111 -0
  25. package/client/builtins/Chart.tsx +875 -0
  26. package/client/builtins/Diff.tsx +722 -0
  27. package/client/builtins/Dropdown.tsx +417 -0
  28. package/client/builtins/FileTree.tsx +87 -0
  29. package/client/builtins/Kbd.tsx +18 -0
  30. package/client/builtins/Screenshot.tsx +209 -0
  31. package/client/builtins/Sparkline.tsx +63 -0
  32. package/client/builtins/Tabs.tsx +169 -0
  33. package/client/builtins/Tooltip.tsx +52 -0
  34. package/client/builtins/chart-data.ts +133 -0
  35. package/client/builtins/index.ts +167 -0
  36. package/client/design/base/editor.css +151 -0
  37. package/client/design/base/prose.css +143 -0
  38. package/client/design/base/reset.css +79 -0
  39. package/client/design/tokens/colors.css +188 -0
  40. package/client/design/tokens/elevation.css +42 -0
  41. package/client/design/tokens/fonts.css +6 -0
  42. package/client/design/tokens/motion.css +76 -0
  43. package/client/design/tokens/spacing.css +34 -0
  44. package/client/design/tokens/typography.css +56 -0
  45. package/client/doc-module-cache.ts +17 -0
  46. package/client/editor-link.ts +27 -0
  47. package/client/entry.tsx +51 -0
  48. package/client/export-doc.ts +80 -0
  49. package/client/export-save.ts +96 -0
  50. package/client/favicon.svg +1 -0
  51. package/client/file-system-access.d.ts +29 -0
  52. package/client/format.ts +17 -0
  53. package/client/hooks.ts +34 -0
  54. package/client/lucide-icons.d.ts +9 -0
  55. package/client/mdx-components-base.ts +32 -0
  56. package/client/mdx-components.ts +18 -0
  57. package/client/mermaid-chart.ts +109 -0
  58. package/client/mermaid-direction.ts +73 -0
  59. package/client/motion.ts +104 -0
  60. package/client/platform.ts +16 -0
  61. package/client/route-path.ts +15 -0
  62. package/client/router.ts +452 -0
  63. package/client/shell/AppShell.tsx +401 -0
  64. package/client/shell/Footer.tsx +33 -0
  65. package/client/shell/NotFoundView.tsx +22 -0
  66. package/client/shell/Sidebar.tsx +169 -0
  67. package/client/shell/StandaloneShell.tsx +65 -0
  68. package/client/shell/TocRail.tsx +53 -0
  69. package/client/shell/TopBar.tsx +117 -0
  70. package/client/shell/use-doc-width.ts +61 -0
  71. package/client/shell/useToc.ts +77 -0
  72. package/client/ssr-entry.tsx +22 -0
  73. package/client/standalone-entry.tsx +51 -0
  74. package/client/state.ts +90 -0
  75. package/client/theme.ts +65 -0
  76. package/client/ui/Breadcrumb.tsx +49 -0
  77. package/client/ui/ConfirmDeleteDialog.tsx +113 -0
  78. package/client/ui/ExpandModal.tsx +342 -0
  79. package/client/ui/Icon.tsx +114 -0
  80. package/client/ui/IconButton.tsx +63 -0
  81. package/client/ui/Kbd.tsx +17 -0
  82. package/client/ui/PageNav.tsx +77 -0
  83. package/client/ui/ResizeHandle.tsx +201 -0
  84. package/client/ui/SearchDialog.tsx +187 -0
  85. package/client/ui/Tag.tsx +44 -0
  86. package/client/ui/Toast.tsx +189 -0
  87. package/client/ui/TocList.tsx +71 -0
  88. package/client/ui/icon-set.ts +102 -0
  89. package/client/ui/toast-count.ts +28 -0
  90. package/dist/cli.js +5091 -0
  91. package/dist/registry.json +703 -0
  92. package/dist/render-worker.js +145 -0
  93. package/package.json +113 -5
  94. package/skills/mdxserve/SKILL.md +178 -0
@@ -0,0 +1,722 @@
1
+ import { useId, useState } from "react";
2
+ import { motion } from "framer-motion";
3
+ import { z } from "zod";
4
+ import { diffLines, diffWordsWithSpace } from "diff";
5
+ import { TRANSITIONS } from "../motion";
6
+ import { CrossFade } from "../CrossFade";
7
+ import { Icon } from "../ui/Icon";
8
+
9
+ import { CodeFrameHeader } from "../CodeBlock";
10
+
11
+ export const diffProps = z.object({
12
+ before: z.string().describe("Original text."),
13
+ after: z.string().describe("New text."),
14
+ language: z
15
+ .string()
16
+ .optional()
17
+ .describe(
18
+ "Label only, shown in the header when `title` isn't set. Shown as a label; the diff is colored by " +
19
+ "change, not by syntax — Shiki runs at build time for fenced code blocks, not for runtime strings " +
20
+ "passed to this component.",
21
+ ),
22
+ title: z
23
+ .string()
24
+ .optional()
25
+ .describe("Filename or heading shown in the header; takes precedence over `language`."),
26
+ view: z.enum(["unified", "split"]).default("unified").describe("Initial view."),
27
+ context: z
28
+ .number()
29
+ .int()
30
+ .min(0)
31
+ .default(3)
32
+ .describe(
33
+ "Unchanged lines kept around each change; longer unchanged runs collapse into an expandable separator.",
34
+ ),
35
+ });
36
+
37
+ export type DiffProps = z.infer<typeof diffProps>;
38
+
39
+ /*
40
+ * Diff computation
41
+ * -----------------
42
+ * Pure and unit-testable: `computeDiff(before, after, context)` is the only
43
+ * entry point React code needs, so it can be exercised without mounting
44
+ * anything.
45
+ */
46
+
47
+ export type DiffRowKind = "context" | "add" | "del";
48
+
49
+ export interface DiffSegment {
50
+ text: string;
51
+ changed: boolean;
52
+ }
53
+
54
+ export interface DiffRow {
55
+ kind: DiffRowKind;
56
+ oldNo?: number;
57
+ newNo?: number;
58
+ text: string;
59
+ segments?: DiffSegment[];
60
+ /** True when this del/add line has a similar counterpart in the adjacent block. */
61
+ paired?: boolean;
62
+ /** For paired lines: index of the add line (within its block) this row is matched to. */
63
+ pairId?: number;
64
+ }
65
+
66
+ export type DiffHunkRow = DiffRow | { kind: "collapsed"; count: number; rows: DiffRow[] };
67
+
68
+ export interface DiffComputation {
69
+ rows: DiffHunkRow[];
70
+ additions: number;
71
+ deletions: number;
72
+ }
73
+
74
+ function stripTrailingNewline(text: string): string {
75
+ return text.endsWith("\n") ? text.slice(0, -1) : text;
76
+ }
77
+
78
+ /** Split a jsdiff line-change value into individual lines, dropping the
79
+ * trailing empty entry produced by a value that itself ends in "\n". */
80
+ function splitLines(value: string): string[] {
81
+ const lines = value.split("\n");
82
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
83
+ return lines;
84
+ }
85
+
86
+ /**
87
+ * Word-level diff between two lines known to differ. Returns per-side
88
+ * segments only when the changed portion is under ~60% of the line (GitHub's
89
+ * rule of thumb) — beyond that, intra-line highlighting is more noise than
90
+ * signal, so the whole line is just left as a plain add/del row.
91
+ */
92
+ function pairLine(
93
+ oldLine: string,
94
+ newLine: string,
95
+ ): { delSegments?: DiffSegment[]; addSegments?: DiffSegment[] } {
96
+ const tokens = diffWordsWithSpace(oldLine, newLine);
97
+ const delSegments: DiffSegment[] = [];
98
+ const addSegments: DiffSegment[] = [];
99
+ let changedLen = 0;
100
+ let totalLen = 0;
101
+
102
+ for (const token of tokens) {
103
+ totalLen += token.value.length;
104
+ if (token.added) {
105
+ addSegments.push({ text: token.value, changed: true });
106
+ changedLen += token.value.length;
107
+ } else if (token.removed) {
108
+ delSegments.push({ text: token.value, changed: true });
109
+ changedLen += token.value.length;
110
+ } else {
111
+ delSegments.push({ text: token.value, changed: false });
112
+ addSegments.push({ text: token.value, changed: false });
113
+ }
114
+ }
115
+
116
+ const ratio = totalLen === 0 ? 0 : changedLen / totalLen;
117
+ if (ratio >= 0.6) return {};
118
+ return { delSegments, addSegments };
119
+ }
120
+
121
+ /** 0..1 similarity of two lines: Dice ratio of shared word-token length (whitespace ignored). */
122
+ function lineSimilarity(left: string, right: string): number {
123
+ const ta = left.trim();
124
+ const tb = right.trim();
125
+ if (ta === tb) return 1;
126
+ if (!ta || !tb) return 0;
127
+ const nonWs = (text: string) => text.replace(/\s+/g, "").length;
128
+ let shared = 0;
129
+ for (const token of diffWordsWithSpace(ta, tb)) {
130
+ if (!token.added && !token.removed) shared += nonWs(token.value);
131
+ }
132
+ const denom = nonWs(ta) + nonWs(tb);
133
+ return denom === 0 ? 0 : (2 * shared) / denom;
134
+ }
135
+
136
+ /**
137
+ * Order-preserving alignment of removed lines to added lines (classic
138
+ * sequence-alignment DP on similarity), keeping only pairs that are clearly
139
+ * related. Returns [delIndex, addIndex] pairs in ascending order.
140
+ */
141
+ function alignLines(dels: string[], adds: string[]): Array<[number, number]> {
142
+ const MIN = 0.65;
143
+ const delCount = dels.length;
144
+ const addCount = adds.length;
145
+ if (delCount === 0 || addCount === 0) return [];
146
+ // Cap the quadratic work for very large blocks: fall back to positional pairing.
147
+ if (delCount * addCount > 40_000)
148
+ return Array.from(
149
+ { length: Math.min(delCount, addCount) },
150
+ (_, index) => [index, index] as [number, number],
151
+ );
152
+ const sim: number[][] = dels.map((delLine) =>
153
+ adds.map((addLine) => lineSimilarity(delLine, addLine)),
154
+ );
155
+ const score: number[][] = Array.from({ length: delCount + 1 }, () =>
156
+ new Array<number>(addCount + 1).fill(0),
157
+ );
158
+ for (let delIndex = 1; delIndex <= delCount; delIndex++) {
159
+ for (let addIndex = 1; addIndex <= addCount; addIndex++) {
160
+ const similarity = sim[delIndex - 1][addIndex - 1];
161
+ const diag = score[delIndex - 1][addIndex - 1] + (similarity >= MIN ? similarity : 0);
162
+ score[delIndex][addIndex] = Math.max(
163
+ diag,
164
+ score[delIndex - 1][addIndex],
165
+ score[delIndex][addIndex - 1],
166
+ );
167
+ }
168
+ }
169
+ const pairs: Array<[number, number]> = [];
170
+ let delIndex = delCount;
171
+ let addIndex = addCount;
172
+ while (delIndex > 0 && addIndex > 0) {
173
+ const similarity = sim[delIndex - 1][addIndex - 1];
174
+ if (
175
+ similarity >= MIN &&
176
+ score[delIndex][addIndex] === score[delIndex - 1][addIndex - 1] + similarity
177
+ ) {
178
+ pairs.push([delIndex - 1, addIndex - 1]);
179
+ delIndex--;
180
+ addIndex--;
181
+ } else if (score[delIndex - 1][addIndex] >= score[delIndex][addIndex - 1]) {
182
+ delIndex--;
183
+ } else {
184
+ addIndex--;
185
+ }
186
+ }
187
+ return pairs.reverse();
188
+ }
189
+
190
+ /**
191
+ * Flatten jsdiff's `diffLines` change objects (each covering a run of one or
192
+ * more lines) into one row per line, pairing up same-length removed/added
193
+ * blocks so they can share intra-line highlights and sit on one row in the
194
+ * split view.
195
+ */
196
+ function flattenChanges(
197
+ before: string,
198
+ after: string,
199
+ ): { rows: DiffRow[]; additions: number; deletions: number } {
200
+ const changes = diffLines(stripTrailingNewline(before), stripTrailingNewline(after));
201
+ const rows: DiffRow[] = [];
202
+ let oldNo = 1;
203
+ let newNo = 1;
204
+ let additions = 0;
205
+ let deletions = 0;
206
+ let index = 0;
207
+
208
+ while (index < changes.length) {
209
+ const change = changes[index];
210
+ const next = changes[index + 1];
211
+
212
+ if (change.removed && next?.added) {
213
+ // A removed block directly followed by an added block: emit all removed
214
+ // lines, then all added lines (GitHub's unified ordering). Lines are
215
+ // paired by similarity (order-preserving) so near-identical lines get
216
+ // intra-line highlights and sit side by side in the split view.
217
+ const delLines = splitLines(change.value);
218
+ const addLines = splitLines(next.value);
219
+ const matches = alignLines(delLines, addLines);
220
+ const delRows: DiffRow[] = [];
221
+ const addRows: DiffRow[] = [];
222
+ const pairedDel = new Map<number, number>();
223
+ for (const [delIndex, addIndex] of matches) pairedDel.set(delIndex, addIndex);
224
+ const pairedAdd = new Set(matches.map(([, addIndex]) => addIndex));
225
+ const segmentsFor = new Map<number, ReturnType<typeof pairLine>>();
226
+ for (const [delIndex, addIndex] of matches)
227
+ segmentsFor.set(delIndex, pairLine(delLines[delIndex], addLines[addIndex]));
228
+ for (let delIndex = 0; delIndex < delLines.length; delIndex++) {
229
+ const seg = segmentsFor.get(delIndex);
230
+ delRows.push({
231
+ kind: "del",
232
+ oldNo: oldNo++,
233
+ text: delLines[delIndex],
234
+ segments: seg?.delSegments,
235
+ paired: pairedDel.has(delIndex),
236
+ pairId: pairedDel.get(delIndex),
237
+ });
238
+ }
239
+ for (let addIndex = 0; addIndex < addLines.length; addIndex++) {
240
+ const delIndex = [...pairedDel.entries()].find(([, pa]) => pa === addIndex)?.[0];
241
+ const seg = delIndex === undefined ? undefined : segmentsFor.get(delIndex);
242
+ addRows.push({
243
+ kind: "add",
244
+ newNo: newNo++,
245
+ text: addLines[addIndex],
246
+ segments: seg?.addSegments,
247
+ paired: pairedAdd.has(addIndex),
248
+ pairId: addIndex,
249
+ });
250
+ }
251
+ rows.push(...delRows, ...addRows);
252
+ deletions += delLines.length;
253
+ additions += addLines.length;
254
+ index += 2;
255
+ continue;
256
+ }
257
+
258
+ if (change.removed) {
259
+ for (const line of splitLines(change.value)) {
260
+ rows.push({ kind: "del", oldNo: oldNo++, text: line });
261
+ deletions++;
262
+ }
263
+ index++;
264
+ continue;
265
+ }
266
+
267
+ if (change.added) {
268
+ for (const line of splitLines(change.value)) {
269
+ rows.push({ kind: "add", newNo: newNo++, text: line });
270
+ additions++;
271
+ }
272
+ index++;
273
+ continue;
274
+ }
275
+
276
+ for (const line of splitLines(change.value)) {
277
+ rows.push({ kind: "context", oldNo: oldNo++, newNo: newNo++, text: line });
278
+ }
279
+ index++;
280
+ }
281
+
282
+ return { rows, additions, deletions };
283
+ }
284
+
285
+ /**
286
+ * Collapse long runs of context rows into `{ kind: "collapsed" }` markers.
287
+ * A run between two changes collapses once it's longer than `2 * context`
288
+ * (keeping `context` lines visible on each side); a run at the very start or
289
+ * end of the diff (no change on one side) collapses once longer than
290
+ * `context`, keeping only the side nearest a change visible.
291
+ */
292
+ function groupHunks(rows: DiffRow[], context: number): DiffHunkRow[] {
293
+ const result: DiffHunkRow[] = [];
294
+ let index = 0;
295
+
296
+ while (index < rows.length) {
297
+ const row = rows[index];
298
+ if (row.kind !== "context") {
299
+ result.push(row);
300
+ index++;
301
+ continue;
302
+ }
303
+
304
+ let runEnd = index;
305
+ while (runEnd < rows.length && rows[runEnd].kind === "context") runEnd++;
306
+ const run = rows.slice(index, runEnd);
307
+ const isLeading = index === 0;
308
+ const isTrailing = runEnd === rows.length;
309
+
310
+ if (isLeading && isTrailing) {
311
+ if (run.length > 2 * context) {
312
+ result.push(...run.slice(0, context));
313
+ const hidden = run.slice(context, run.length - context);
314
+ if (hidden.length > 0)
315
+ result.push({ kind: "collapsed", count: hidden.length, rows: hidden });
316
+ result.push(...run.slice(run.length - context));
317
+ } else {
318
+ result.push(...run);
319
+ }
320
+ } else if (isLeading) {
321
+ if (run.length > context) {
322
+ const hidden = run.slice(0, run.length - context);
323
+ result.push({ kind: "collapsed", count: hidden.length, rows: hidden });
324
+ result.push(...run.slice(run.length - context));
325
+ } else {
326
+ result.push(...run);
327
+ }
328
+ } else if (isTrailing) {
329
+ if (run.length > context) {
330
+ result.push(...run.slice(0, context));
331
+ const hidden = run.slice(context);
332
+ result.push({ kind: "collapsed", count: hidden.length, rows: hidden });
333
+ } else {
334
+ result.push(...run);
335
+ }
336
+ } else {
337
+ if (run.length > 2 * context) {
338
+ result.push(...run.slice(0, context));
339
+ const hidden = run.slice(context, run.length - context);
340
+ result.push({ kind: "collapsed", count: hidden.length, rows: hidden });
341
+ result.push(...run.slice(run.length - context));
342
+ } else {
343
+ result.push(...run);
344
+ }
345
+ }
346
+
347
+ index = runEnd;
348
+ }
349
+
350
+ return result;
351
+ }
352
+
353
+ export function computeDiff(before: string, after: string, context: number): DiffComputation {
354
+ const { rows, additions, deletions } = flattenChanges(before, after);
355
+ return { rows: groupHunks(rows, context), additions, deletions };
356
+ }
357
+
358
+ /*
359
+ * Rendering
360
+ * ---------
361
+ */
362
+
363
+ type FlatItem = DiffRow | { kind: "collapsed"; index: number; count: number };
364
+
365
+ /** Replace still-collapsed markers with a stand-in that carries their
366
+ * original index (for the expand click handler); splice expanded ones'
367
+ * hidden rows directly back into the flow. */
368
+ function applyExpansion(rows: DiffHunkRow[], expanded: Set<number>): FlatItem[] {
369
+ const out: FlatItem[] = [];
370
+ rows.forEach((row, index) => {
371
+ if (row.kind === "collapsed") {
372
+ if (expanded.has(index)) out.push(...row.rows);
373
+ else out.push({ kind: "collapsed", index, count: row.count });
374
+ } else {
375
+ out.push(row);
376
+ }
377
+ });
378
+ return out;
379
+ }
380
+
381
+ type Block =
382
+ { type: "rows"; rows: DiffRow[] } | { type: "separator"; index: number; count: number };
383
+
384
+ /** Group a flattened, expansion-applied item list into runs of plain rows
385
+ * and standalone separators, so a separator can render full-width while
386
+ * everything else renders as an aligned table. */
387
+ function partitionBlocks(items: FlatItem[]): Block[] {
388
+ const blocks: Block[] = [];
389
+ let current: DiffRow[] = [];
390
+ for (const item of items) {
391
+ if (item.kind === "collapsed") {
392
+ if (current.length > 0) {
393
+ blocks.push({ type: "rows", rows: current });
394
+ current = [];
395
+ }
396
+ blocks.push({ type: "separator", index: item.index, count: item.count });
397
+ } else {
398
+ current.push(item);
399
+ }
400
+ }
401
+ if (current.length > 0) blocks.push({ type: "rows", rows: current });
402
+ return blocks;
403
+ }
404
+
405
+ function rowClasses(kind: DiffRowKind): { row: string; gutter: string; text: string } {
406
+ if (kind === "add")
407
+ return { row: "bg-status-ok-bg", gutter: "text-status-ok-fg", text: "text-status-ok-fg" };
408
+ if (kind === "del")
409
+ return {
410
+ row: "bg-status-danger-bg",
411
+ gutter: "text-status-danger-fg",
412
+ text: "text-status-danger-fg",
413
+ };
414
+ return { row: "", gutter: "text-code-gutter", text: "text-code-fg" };
415
+ }
416
+
417
+ /** Word-level highlight: one step stronger than the row tint it sits on. */
418
+ function highlightStyle(kind: "add" | "del"): { backgroundColor: string } {
419
+ const fgVar = kind === "add" ? "var(--status-ok-fg)" : "var(--status-danger-fg)";
420
+ return { backgroundColor: `color-mix(in oklab, ${fgVar} 35%, transparent)` };
421
+ }
422
+
423
+ function marker(kind: DiffRowKind): string {
424
+ return kind === "add" ? "+" : kind === "del" ? "−" : " ";
425
+ }
426
+
427
+ function Segments({ row }: { row: DiffRow }) {
428
+ if (row.text === "") return <>{" "}</>;
429
+ if (!row.segments) return <>{row.text}</>;
430
+ const style = row.kind === "add" || row.kind === "del" ? highlightStyle(row.kind) : undefined;
431
+ return (
432
+ <>
433
+ {row.segments.map((segment, index) =>
434
+ segment.changed ? (
435
+ <span key={index} className="rounded-[2px]" style={style}>
436
+ {segment.text}
437
+ </span>
438
+ ) : (
439
+ <span key={index}>{segment.text}</span>
440
+ ),
441
+ )}
442
+ </>
443
+ );
444
+ }
445
+
446
+ function Separator({ count, onExpand }: { count: number; onExpand: () => void }) {
447
+ return (
448
+ <button
449
+ type="button"
450
+ onClick={onExpand}
451
+ className="block w-full cursor-pointer bg-surface-accent-soft px-4 py-1 text-left text-xs text-text-accent hover:brightness-95"
452
+ >
453
+ {`↕ ${count} unchanged line${count === 1 ? "" : "s"}`}
454
+ </button>
455
+ );
456
+ }
457
+
458
+ function UnifiedRows({ rows }: { rows: DiffRow[] }) {
459
+ return (
460
+ <table className="min-w-full border-collapse text-left">
461
+ <tbody>
462
+ {rows.map((row, index) => {
463
+ const { row: rowClass, gutter, text } = rowClasses(row.kind);
464
+ return (
465
+ <tr key={index} className={rowClass}>
466
+ <td className={`w-1 select-none whitespace-pre px-2 text-right align-top ${gutter}`}>
467
+ {row.oldNo ?? ""}
468
+ </td>
469
+ <td className={`w-1 select-none whitespace-pre px-2 text-right align-top ${gutter}`}>
470
+ {row.newNo ?? ""}
471
+ </td>
472
+ <td className={`w-1 select-none whitespace-pre px-1 text-center align-top ${gutter}`}>
473
+ {marker(row.kind)}
474
+ </td>
475
+ <td className={`whitespace-pre px-2 align-top ${text}`}>
476
+ <Segments row={row} />
477
+ </td>
478
+ </tr>
479
+ );
480
+ })}
481
+ </tbody>
482
+ </table>
483
+ );
484
+ }
485
+
486
+ function UnifiedView({
487
+ rows,
488
+ expanded,
489
+ onExpand,
490
+ }: {
491
+ rows: DiffHunkRow[];
492
+ expanded: Set<number>;
493
+ onExpand: (index: number) => void;
494
+ }) {
495
+ const blocks = partitionBlocks(applyExpansion(rows, expanded));
496
+ return (
497
+ <div className="overflow-x-auto [tab-size:2]">
498
+ {blocks.map((block, index) =>
499
+ block.type === "separator" ? (
500
+ <Separator key={index} count={block.count} onExpand={() => onExpand(block.index)} />
501
+ ) : (
502
+ <UnifiedRows key={index} rows={block.rows} />
503
+ ),
504
+ )}
505
+ </div>
506
+ );
507
+ }
508
+
509
+ interface SplitRow {
510
+ left?: DiffRow;
511
+ right?: DiffRow;
512
+ }
513
+
514
+ function toSplitRows(rows: DiffRow[]): SplitRow[] {
515
+ const out: SplitRow[] = [];
516
+ let index = 0;
517
+ while (index < rows.length) {
518
+ const row = rows[index];
519
+ if (row.kind === "context") {
520
+ out.push({ left: row, right: row });
521
+ index++;
522
+ continue;
523
+ }
524
+ // Collect the run of removed lines and the run of added lines that
525
+ // follows it. Paired lines (matched by similarity) share a row; the
526
+ // unpaired lines between them render against an empty cell, preserving
527
+ // order on both sides.
528
+ const dels: DiffRow[] = [];
529
+ const adds: DiffRow[] = [];
530
+ while (index < rows.length && rows[index].kind === "del") dels.push(rows[index++]);
531
+ while (index < rows.length && rows[index].kind === "add") adds.push(rows[index++]);
532
+ let addIndex = 0;
533
+ for (const del of dels) {
534
+ if (del.paired && del.pairId !== undefined) {
535
+ while (addIndex < del.pairId) out.push({ right: adds[addIndex++] });
536
+ out.push({ left: del, right: adds[addIndex++] });
537
+ } else {
538
+ out.push({ left: del });
539
+ }
540
+ }
541
+ while (addIndex < adds.length) out.push({ right: adds[addIndex++] });
542
+ }
543
+ return out;
544
+ }
545
+
546
+ function SplitCell({ row, side }: { row?: DiffRow; side: "left" | "right" }) {
547
+ if (!row) {
548
+ return (
549
+ <tr className="bg-surface-sunken">
550
+ <td className="select-none whitespace-pre px-2 text-right text-code-gutter">{" "}</td>
551
+ <td className="whitespace-pre px-2 text-code-gutter">{" "}</td>
552
+ </tr>
553
+ );
554
+ }
555
+ const { row: rowClass, gutter, text } = rowClasses(row.kind);
556
+ return (
557
+ <tr className={rowClass}>
558
+ <td className={`w-1 select-none whitespace-pre px-2 text-right align-top ${gutter}`}>
559
+ {(side === "left" ? row.oldNo : row.newNo) ?? ""}
560
+ </td>
561
+ <td className={`whitespace-pre px-2 align-top ${text}`}>
562
+ <Segments row={row} />
563
+ </td>
564
+ </tr>
565
+ );
566
+ }
567
+
568
+ function SplitRows({ rows }: { rows: DiffRow[] }) {
569
+ const splitRows = toSplitRows(rows);
570
+ return (
571
+ <div className="grid grid-cols-2">
572
+ <div className="overflow-x-auto border-r border-code-border">
573
+ <table className="min-w-full border-collapse text-left">
574
+ <tbody>
575
+ {splitRows.map((sr, index) => (
576
+ <SplitCell key={index} row={sr.left} side="left" />
577
+ ))}
578
+ </tbody>
579
+ </table>
580
+ </div>
581
+ <div className="overflow-x-auto">
582
+ <table className="min-w-full border-collapse text-left">
583
+ <tbody>
584
+ {splitRows.map((sr, index) => (
585
+ <SplitCell key={index} row={sr.right} side="right" />
586
+ ))}
587
+ </tbody>
588
+ </table>
589
+ </div>
590
+ </div>
591
+ );
592
+ }
593
+
594
+ function SplitView({
595
+ rows,
596
+ expanded,
597
+ onExpand,
598
+ }: {
599
+ rows: DiffHunkRow[];
600
+ expanded: Set<number>;
601
+ onExpand: (index: number) => void;
602
+ }) {
603
+ const blocks = partitionBlocks(applyExpansion(rows, expanded));
604
+ return (
605
+ <div className="[tab-size:2]">
606
+ {blocks.map((block, index) =>
607
+ block.type === "separator" ? (
608
+ <Separator key={index} count={block.count} onExpand={() => onExpand(block.index)} />
609
+ ) : (
610
+ <SplitRows key={index} rows={block.rows} />
611
+ ),
612
+ )}
613
+ </div>
614
+ );
615
+ }
616
+
617
+ export default function Diff({
618
+ before,
619
+ after,
620
+ language,
621
+ title,
622
+ view = "unified",
623
+ context = 3,
624
+ }: DiffProps) {
625
+ const [activeView, setActiveView] = useState<"unified" | "split">(view);
626
+ const [expanded, setExpanded] = useState<Set<number>>(new Set());
627
+ const [copied, setCopied] = useState(false);
628
+ const toggleId = useId();
629
+
630
+ const { rows, additions, deletions } = computeDiff(before, after, context);
631
+ const label = title ?? language ?? "diff";
632
+
633
+ function expand(index: number) {
634
+ setExpanded((prev) => {
635
+ const next = new Set(prev);
636
+ next.add(index);
637
+ return next;
638
+ });
639
+ }
640
+
641
+ async function handleCopy() {
642
+ try {
643
+ await navigator.clipboard.writeText(after);
644
+ setCopied(true);
645
+ setTimeout(() => setCopied(false), 1500);
646
+ } catch {
647
+ // clipboard unavailable; ignore
648
+ }
649
+ }
650
+
651
+ const header = (
652
+ <span className="flex min-w-0 items-center gap-3">
653
+ <span className="truncate">{label}</span>
654
+ <span className="whitespace-nowrap font-mono font-normal leading-[1.62] text-[length:var(--size-xs)]">
655
+ <span className="text-status-ok-fg">{`+${additions}`}</span>{" "}
656
+ <span className="text-status-danger-fg">{`−${deletions}`}</span>
657
+ </span>
658
+ </span>
659
+ );
660
+
661
+ const actions = (
662
+ <>
663
+ <div role="tablist" className="inline-flex rounded-md bg-surface-sunken p-0.5 text-xs">
664
+ {(["unified", "split"] as const).map((option) => (
665
+ <button
666
+ key={option}
667
+ type="button"
668
+ role="tab"
669
+ aria-selected={activeView === option}
670
+ onClick={() => setActiveView(option)}
671
+ className={
672
+ "relative cursor-pointer rounded px-2 py-0.5 capitalize transition-colors " +
673
+ (activeView === option
674
+ ? "text-text-heading"
675
+ : "text-text-subtle hover:text-text-body")
676
+ }
677
+ >
678
+ {activeView === option ? (
679
+ <motion.span
680
+ layoutId={`${toggleId}-pill`}
681
+ transition={TRANSITIONS.snap}
682
+ className="absolute inset-0 rounded bg-surface-hover"
683
+ />
684
+ ) : null}
685
+ <span className="relative">{option}</span>
686
+ </button>
687
+ ))}
688
+ </div>
689
+ <button
690
+ type="button"
691
+ onClick={handleCopy}
692
+ className="inline-flex cursor-pointer items-center gap-1.5 text-xs text-text-subtle transition-colors hover:text-text-body"
693
+ >
694
+ <Icon name={copied ? "check" : "copy"} size="sm" />
695
+ {copied ? "Copied" : "Copy"}
696
+ </button>
697
+ </>
698
+ );
699
+
700
+ return (
701
+ <motion.div
702
+ initial={{ opacity: 0, y: 6 }}
703
+ animate={{ opacity: 1, y: 0 }}
704
+ transition={TRANSITIONS.base}
705
+ className="not-prose overflow-hidden rounded-lg border border-code-border bg-code-bg"
706
+ >
707
+ <CodeFrameHeader label={header} actions={actions} />
708
+ <div className="font-mono font-normal text-[length:var(--size-xs)] leading-5">
709
+ <CrossFade
710
+ active={activeView}
711
+ panes={[
712
+ {
713
+ key: "unified",
714
+ node: <UnifiedView rows={rows} expanded={expanded} onExpand={expand} />,
715
+ },
716
+ { key: "split", node: <SplitView rows={rows} expanded={expanded} onExpand={expand} /> },
717
+ ]}
718
+ />
719
+ </div>
720
+ </motion.div>
721
+ );
722
+ }