@dowel-ui/react 0.2.0 → 0.4.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 (141) hide show
  1. package/README.md +14 -7
  2. package/dist/components/ai-agent-plan/ai-agent-plan.d.ts +50 -0
  3. package/dist/components/ai-agent-plan/ai-agent-plan.d.ts.map +1 -0
  4. package/dist/components/ai-agent-plan/ai-agent-plan.js +156 -0
  5. package/dist/components/ai-agent-plan/ai-agent-plan.js.map +1 -0
  6. package/dist/components/ai-agent-plan/index.d.ts +2 -0
  7. package/dist/components/ai-agent-plan/index.js +2 -0
  8. package/dist/components/ai-agent-plan/meta.js +17 -0
  9. package/dist/components/ai-agent-plan/meta.js.map +1 -0
  10. package/dist/components/ai-agent-status/ai-agent-status.d.ts +1 -1
  11. package/dist/components/ai-approval-request/ai-approval-request.d.ts +67 -0
  12. package/dist/components/ai-approval-request/ai-approval-request.d.ts.map +1 -0
  13. package/dist/components/ai-approval-request/ai-approval-request.js +218 -0
  14. package/dist/components/ai-approval-request/ai-approval-request.js.map +1 -0
  15. package/dist/components/ai-approval-request/index.d.ts +2 -0
  16. package/dist/components/ai-approval-request/index.js +2 -0
  17. package/dist/components/ai-approval-request/meta.js +17 -0
  18. package/dist/components/ai-approval-request/meta.js.map +1 -0
  19. package/dist/components/ai-disclosure/ai-disclosure.d.ts +92 -0
  20. package/dist/components/ai-disclosure/ai-disclosure.d.ts.map +1 -0
  21. package/dist/components/ai-disclosure/ai-disclosure.js +122 -0
  22. package/dist/components/ai-disclosure/ai-disclosure.js.map +1 -0
  23. package/dist/components/ai-disclosure/index.d.ts +2 -0
  24. package/dist/components/ai-disclosure/index.js +2 -0
  25. package/dist/components/ai-disclosure/meta.js +17 -0
  26. package/dist/components/ai-disclosure/meta.js.map +1 -0
  27. package/dist/components/ai-response/ai-response.d.ts.map +1 -1
  28. package/dist/components/ai-response/ai-response.js +1 -0
  29. package/dist/components/ai-response/ai-response.js.map +1 -1
  30. package/dist/components/ai-structured-output/ai-structured-output.d.ts.map +1 -1
  31. package/dist/components/ai-structured-output/ai-structured-output.js +13 -2
  32. package/dist/components/ai-structured-output/ai-structured-output.js.map +1 -1
  33. package/dist/components/ai-tool/ai-tool.d.ts +1 -1
  34. package/dist/components/alert/alert.d.ts +1 -1
  35. package/dist/components/avatar/avatar.d.ts +1 -1
  36. package/dist/components/badge/badge.d.ts +1 -1
  37. package/dist/components/button/button.d.ts +1 -1
  38. package/dist/components/date-picker/date-picker.js +1 -1
  39. package/dist/components/diff-viewer/diff-model.d.ts +69 -0
  40. package/dist/components/diff-viewer/diff-model.d.ts.map +1 -0
  41. package/dist/components/diff-viewer/diff-model.js +180 -0
  42. package/dist/components/diff-viewer/diff-model.js.map +1 -0
  43. package/dist/components/diff-viewer/diff-viewer.d.ts +42 -0
  44. package/dist/components/diff-viewer/diff-viewer.d.ts.map +1 -0
  45. package/dist/components/diff-viewer/diff-viewer.js +212 -0
  46. package/dist/components/diff-viewer/diff-viewer.js.map +1 -0
  47. package/dist/components/diff-viewer/index.d.ts +3 -0
  48. package/dist/components/diff-viewer/index.js +3 -0
  49. package/dist/components/diff-viewer/meta.js +17 -0
  50. package/dist/components/diff-viewer/meta.js.map +1 -0
  51. package/dist/components/file-upload/file-upload.d.ts +46 -0
  52. package/dist/components/file-upload/file-upload.d.ts.map +1 -0
  53. package/dist/components/file-upload/file-upload.js +204 -0
  54. package/dist/components/file-upload/file-upload.js.map +1 -0
  55. package/dist/components/file-upload/index.d.ts +3 -0
  56. package/dist/components/file-upload/index.js +3 -0
  57. package/dist/components/file-upload/meta.js +17 -0
  58. package/dist/components/file-upload/meta.js.map +1 -0
  59. package/dist/components/file-upload/upload-queue.d.ts +87 -0
  60. package/dist/components/file-upload/upload-queue.d.ts.map +1 -0
  61. package/dist/components/file-upload/upload-queue.js +249 -0
  62. package/dist/components/file-upload/upload-queue.js.map +1 -0
  63. package/dist/components/log-viewer/index.d.ts +3 -0
  64. package/dist/components/log-viewer/index.js +3 -0
  65. package/dist/components/log-viewer/log-stream.d.ts +68 -0
  66. package/dist/components/log-viewer/log-stream.d.ts.map +1 -0
  67. package/dist/components/log-viewer/log-stream.js +120 -0
  68. package/dist/components/log-viewer/log-stream.js.map +1 -0
  69. package/dist/components/log-viewer/log-viewer.d.ts +51 -0
  70. package/dist/components/log-viewer/log-viewer.d.ts.map +1 -0
  71. package/dist/components/log-viewer/log-viewer.js +284 -0
  72. package/dist/components/log-viewer/log-viewer.js.map +1 -0
  73. package/dist/components/log-viewer/meta.js +17 -0
  74. package/dist/components/log-viewer/meta.js.map +1 -0
  75. package/dist/components/progress/progress.d.ts +1 -1
  76. package/dist/components/progress/progress.js +1 -0
  77. package/dist/components/progress/progress.js.map +1 -1
  78. package/dist/components/spinner/spinner.d.ts +1 -1
  79. package/dist/components/spinner/spinner.js +1 -0
  80. package/dist/components/spinner/spinner.js.map +1 -1
  81. package/dist/components/tags-input/index.d.ts +2 -0
  82. package/dist/components/tags-input/index.js +2 -0
  83. package/dist/components/tags-input/meta.js +17 -0
  84. package/dist/components/tags-input/meta.js.map +1 -0
  85. package/dist/components/tags-input/tags-input.d.ts +47 -0
  86. package/dist/components/tags-input/tags-input.d.ts.map +1 -0
  87. package/dist/components/tags-input/tags-input.js +182 -0
  88. package/dist/components/tags-input/tags-input.js.map +1 -0
  89. package/dist/components/time-range-picker/index.d.ts +3 -0
  90. package/dist/components/time-range-picker/index.js +3 -0
  91. package/dist/components/time-range-picker/meta.js +22 -0
  92. package/dist/components/time-range-picker/meta.js.map +1 -0
  93. package/dist/components/time-range-picker/time-expression.d.ts +88 -0
  94. package/dist/components/time-range-picker/time-expression.d.ts.map +1 -0
  95. package/dist/components/time-range-picker/time-expression.js +335 -0
  96. package/dist/components/time-range-picker/time-expression.js.map +1 -0
  97. package/dist/components/time-range-picker/time-range-picker.d.ts +65 -0
  98. package/dist/components/time-range-picker/time-range-picker.d.ts.map +1 -0
  99. package/dist/components/time-range-picker/time-range-picker.js +377 -0
  100. package/dist/components/time-range-picker/time-range-picker.js.map +1 -0
  101. package/dist/components/toast/toast.d.ts +1 -1
  102. package/dist/index.d.ts +21 -1
  103. package/dist/index.js +14 -2
  104. package/dist/registry/components.d.ts.map +1 -1
  105. package/dist/registry/components.js +66 -50
  106. package/dist/registry/components.js.map +1 -1
  107. package/package.json +6 -4
  108. package/src/components/ai-agent-plan/ai-agent-plan.tsx +0 -0
  109. package/src/components/ai-agent-plan/index.ts +10 -0
  110. package/src/components/ai-agent-plan/meta.ts +19 -0
  111. package/src/components/ai-approval-request/ai-approval-request.tsx +368 -0
  112. package/src/components/ai-approval-request/index.ts +7 -0
  113. package/src/components/ai-approval-request/meta.ts +21 -0
  114. package/src/components/ai-disclosure/ai-disclosure.tsx +244 -0
  115. package/src/components/ai-disclosure/index.ts +9 -0
  116. package/src/components/ai-disclosure/meta.ts +23 -0
  117. package/src/components/ai-response/ai-response.tsx +1 -0
  118. package/src/components/ai-structured-output/ai-structured-output.tsx +25 -1
  119. package/src/components/diff-viewer/diff-model.ts +256 -0
  120. package/src/components/diff-viewer/diff-viewer.tsx +339 -0
  121. package/src/components/diff-viewer/index.ts +17 -0
  122. package/src/components/diff-viewer/meta.ts +20 -0
  123. package/src/components/file-upload/file-upload.tsx +322 -0
  124. package/src/components/file-upload/index.ts +20 -0
  125. package/src/components/file-upload/meta.ts +21 -0
  126. package/src/components/file-upload/upload-queue.ts +337 -0
  127. package/src/components/log-viewer/index.ts +21 -0
  128. package/src/components/log-viewer/log-stream.ts +176 -0
  129. package/src/components/log-viewer/log-viewer.tsx +424 -0
  130. package/src/components/log-viewer/meta.ts +21 -0
  131. package/src/components/progress/progress.tsx +3 -0
  132. package/src/components/spinner/spinner.tsx +5 -0
  133. package/src/components/tags-input/index.ts +1 -0
  134. package/src/components/tags-input/meta.ts +23 -0
  135. package/src/components/tags-input/tags-input.tsx +300 -0
  136. package/src/components/time-range-picker/index.ts +25 -0
  137. package/src/components/time-range-picker/meta.ts +22 -0
  138. package/src/components/time-range-picker/time-expression.ts +491 -0
  139. package/src/components/time-range-picker/time-range-picker.tsx +522 -0
  140. package/src/index.ts +8 -0
  141. package/src/registry/components.ts +16 -0
@@ -0,0 +1,244 @@
1
+ "use client";
2
+
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+ import type { ComponentPropsWithRef, ReactNode } from "react";
5
+
6
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover";
7
+ import { focusRing, iconSlot } from "@/lib/styles";
8
+ import { cn } from "@/lib/utils";
9
+
10
+ /**
11
+ * Telling someone they are looking at AI.
12
+ *
13
+ * Not one registry ships this — not AI Elements, assistant-ui, prompt-kit,
14
+ * CopilotKit or shadcn — while every one of them ships the chat surface that
15
+ * needs it. The gap is the reason this exists.
16
+ *
17
+ * The four `kind`s are not invented. They are the four human-visible situations
18
+ * the EU AI Act's Article 50 actually creates, which has applied since 2 August
19
+ * 2026: you are talking to a machine (50(1)); this media was generated or
20
+ * manipulated and resembles something real (50(4), "deep fakes"); this text was
21
+ * published to inform the public (50(4)); and the assisted case, which the same
22
+ * paragraph exempts where there is human review or editorial control — worth
23
+ * saying out loud rather than leaving to inference.
24
+ *
25
+ * What this cannot do, and what no React component can: Article 50(2) requires
26
+ * synthetic output to be marked *in a machine-readable format*, in the artifact
27
+ * itself, by whoever generated it. Rendering a badge does not do that, and a
28
+ * component that let you believe otherwise would be worse than none. Read the
29
+ * accessibility note and the docs page: this is a disclosure control, not a
30
+ * compliance product, and nothing here is legal advice.
31
+ */
32
+
33
+ export type DisclosureKind = "interaction" | "generated" | "manipulated" | "assisted";
34
+
35
+ /**
36
+ * What each kind says by default.
37
+ *
38
+ * Phrased for a reader rather than for a lawyer, and every one of them is
39
+ * overridable — the Act says the disclosure must be clear, not that it must use
40
+ * these words, and the right words depend on the product.
41
+ */
42
+ const DEFAULT_LABEL: Record<DisclosureKind, string> = {
43
+ interaction: "You are chatting with an AI assistant",
44
+ generated: "Generated by AI",
45
+ manipulated: "This media was altered by AI",
46
+ assisted: "AI-assisted, reviewed by a person",
47
+ };
48
+
49
+ const disclosureVariants = cva(
50
+ cn(
51
+ "inline-flex w-fit items-center gap-1.5 font-medium",
52
+ "[&_svg:not([class*='size-'])]:size-3.5",
53
+ iconSlot,
54
+ ),
55
+ {
56
+ variants: {
57
+ variant: {
58
+ /** Sits beside the thing it describes. */
59
+ inline: "rounded-full border border-border-strong px-2 py-0.5 text-xs",
60
+ /** Above a conversation, where Article 50(1) usually belongs. */
61
+ banner:
62
+ "w-full rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground",
63
+ /**
64
+ * Over the corner of an image or video. `--overlay` is a dark scrim in
65
+ * every shipped theme, so the text is white on purpose rather than
66
+ * following the theme — a label that inverts over a photograph is
67
+ * unreadable on half of them.
68
+ */
69
+ overlay: "rounded-md bg-overlay px-2 py-1 text-2xs text-white backdrop-blur-sm",
70
+ },
71
+ },
72
+ defaultVariants: { variant: "inline" },
73
+ },
74
+ );
75
+
76
+ function SparkIcon() {
77
+ return (
78
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
79
+ <path
80
+ d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Z"
81
+ stroke="currentColor"
82
+ strokeWidth="2"
83
+ strokeLinejoin="round"
84
+ />
85
+ </svg>
86
+ );
87
+ }
88
+
89
+ export interface AIDisclosureProps
90
+ extends
91
+ Omit<ComponentPropsWithRef<"div">, "children">,
92
+ VariantProps<typeof disclosureVariants> {
93
+ kind: DisclosureKind;
94
+ /** Replaces the default wording. The default is a starting point, not a rule. */
95
+ label?: ReactNode;
96
+ /**
97
+ * Replaces the mark. The Commission publishes three icons for this and lets
98
+ * anyone use them without attribution — they are deliberately not bundled
99
+ * here, because an official mark shipped inside a component library ends up
100
+ * on content nobody checked. Bring your own, and the Commission's own words
101
+ * apply: using them "does not establish legal compliance by itself".
102
+ */
103
+ icon?: ReactNode;
104
+ /** A provenance panel, or anything else that belongs after the label. */
105
+ children?: ReactNode;
106
+ }
107
+
108
+ /**
109
+ * A disclosure, in words.
110
+ *
111
+ * The icon is decorative and the text is the disclosure. A sparkle alone is not
112
+ * a notice — it is a decoration that a sighted reader may or may not decode and
113
+ * that a screen reader passes over in silence, which is the failure mode the
114
+ * whole component exists to avoid.
115
+ */
116
+ export function AIDisclosure({
117
+ className,
118
+ kind,
119
+ variant,
120
+ label,
121
+ icon,
122
+ children,
123
+ ...props
124
+ }: AIDisclosureProps) {
125
+ return (
126
+ <div
127
+ data-slot="ai-disclosure"
128
+ data-kind={kind}
129
+ className={cn(disclosureVariants({ variant }), className)}
130
+ {...props}
131
+ >
132
+ {icon ?? <SparkIcon />}
133
+ <span>{label ?? DEFAULT_LABEL[kind]}</span>
134
+ {children}
135
+ </div>
136
+ );
137
+ }
138
+
139
+ /* ------------------------------------------------------------------ */
140
+ /* Provenance */
141
+ /* ------------------------------------------------------------------ */
142
+
143
+ export interface ProvenanceClaim {
144
+ label: string;
145
+ value: ReactNode;
146
+ }
147
+
148
+ export interface AIDisclosureProvenanceProps extends Omit<
149
+ ComponentPropsWithRef<"button">,
150
+ "children"
151
+ > {
152
+ /**
153
+ * What is being claimed about this content, and by whom.
154
+ *
155
+ * Claims, not findings. They arrive as data — from a C2PA manifest your
156
+ * server read, from your own pipeline, from a vendor — and this renders them.
157
+ */
158
+ claims: ProvenanceClaim[];
159
+ /** Who asserts the claims. Named, because an unattributed claim is a rumour. */
160
+ source?: string;
161
+ /**
162
+ * Whether something you trust checked the claims, and said so.
163
+ *
164
+ * Supplied, never computed. Verifying a C2PA manifest means parsing signed
165
+ * COSE and walking a certificate chain; doing that in the browser would mean
166
+ * a wasm blob you cannot read, and "you own the source" would stop being
167
+ * true. It also cannot be trusted client-side even when it succeeds, because
168
+ * the page doing the checking is the page making the claim. Verify on a
169
+ * server and pass the answer down. Left undefined, this says so.
170
+ */
171
+ verified?: boolean;
172
+ triggerLabel?: string;
173
+ }
174
+
175
+ type CheckStatus = "verified" | "failed" | "unchecked";
176
+
177
+ function statusText(status: CheckStatus, source?: string): string {
178
+ const by = source ? ` by ${source}` : "";
179
+ if (status === "verified") return `Checked${by} and the claims matched.`;
180
+ if (status === "failed") return `Checked${by} and the claims did not match.`;
181
+ return "Not checked. These are claims made about the content, not findings.";
182
+ }
183
+
184
+ /**
185
+ * The metadata behind a disclosure, presented as claims rather than proof.
186
+ *
187
+ * The distinction is the entire point. "Made with Model X on 3 September" looks
188
+ * like a fact and is a string somebody put in a file. Rendering it beside a
189
+ * checkmark would launder an assertion into evidence, so the panel names who is
190
+ * asserting and states plainly whether anyone checked.
191
+ */
192
+ export function AIDisclosureProvenance({
193
+ className,
194
+ claims,
195
+ source,
196
+ verified,
197
+ triggerLabel = "Content details",
198
+ ...props
199
+ }: AIDisclosureProvenanceProps) {
200
+ const status = verified === undefined ? "unchecked" : verified ? "verified" : "failed";
201
+
202
+ return (
203
+ <Popover>
204
+ <PopoverTrigger asChild>
205
+ <button
206
+ type="button"
207
+ data-slot="ai-disclosure-provenance-trigger"
208
+ className={cn(
209
+ "rounded-sm underline decoration-dotted underline-offset-2 hover:decoration-solid",
210
+ focusRing,
211
+ className,
212
+ )}
213
+ {...props}
214
+ >
215
+ {triggerLabel}
216
+ </button>
217
+ </PopoverTrigger>
218
+ <PopoverContent aria-label={triggerLabel} className="w-80 text-xs">
219
+ <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
220
+ {claims.map((claim) => (
221
+ <div key={claim.label} className="contents">
222
+ <dt className="text-muted-foreground">{claim.label}</dt>
223
+ <dd className="break-words">{claim.value}</dd>
224
+ </div>
225
+ ))}
226
+ </dl>
227
+ {/* Said in text, and last, so it qualifies everything above it rather
228
+ than being a tick a reader has to interpret. */}
229
+ <p
230
+ data-slot="ai-disclosure-provenance-status"
231
+ data-status={status}
232
+ className={cn(
233
+ "mt-3 border-t border-border pt-2",
234
+ status === "failed" ? "text-destructive" : "text-muted-foreground",
235
+ )}
236
+ >
237
+ {statusText(status, source)}
238
+ </p>
239
+ </PopoverContent>
240
+ </Popover>
241
+ );
242
+ }
243
+
244
+ export { disclosureVariants };
@@ -0,0 +1,9 @@
1
+ export {
2
+ AIDisclosure,
3
+ AIDisclosureProvenance,
4
+ disclosureVariants,
5
+ type AIDisclosureProps,
6
+ type AIDisclosureProvenanceProps,
7
+ type DisclosureKind,
8
+ type ProvenanceClaim,
9
+ } from "./ai-disclosure";
@@ -0,0 +1,23 @@
1
+ import { defineMeta } from "@/registry/schema";
2
+
3
+ export const meta = defineMeta({
4
+ name: "ai-disclosure",
5
+ title: "AI Disclosure",
6
+ description: "Tells a reader they are looking at AI, and what is actually known about it.",
7
+ category: "ai",
8
+ status: "stable",
9
+ dependencies: ["class-variance-authority"],
10
+ registryDependencies: ["popover"],
11
+ files: ["ai-disclosure.tsx"],
12
+ a11y:
13
+ "The text is the disclosure and the icon is decoration, marked aria-hidden — a sparkle alone " +
14
+ "is a mark a sighted reader may not decode and a screen reader passes over in silence, which " +
15
+ "is the exact failure this component exists to prevent. Each of the four kinds carries " +
16
+ "wording of its own, so the distinction between generated, manipulated and human-reviewed " +
17
+ "survives for anyone who cannot see the styling. The provenance panel states in words " +
18
+ "whether anyone checked the claims and who, rather than implying it with a tick, and its " +
19
+ "three states are told apart by their sentences rather than by colour. " +
20
+ "Scope: this renders the human-visible disclosures of EU AI Act Article 50(1) and 50(4). " +
21
+ "It cannot satisfy 50(2), which requires machine-readable marking inside the artifact by " +
22
+ "whoever generated it, and no React component can. Not legal advice.",
23
+ });
@@ -50,6 +50,7 @@ export function ResponseCaret({ className, ...props }: ComponentPropsWithRef<"sp
50
50
  <span
51
51
  data-slot="response-caret"
52
52
  aria-hidden="true"
53
+ data-motion="indicator"
53
54
  className={cn(
54
55
  "ml-0.5 inline-block h-[1em] w-[2px] translate-y-[0.15em] animate-caret bg-current align-baseline",
55
56
  className,
@@ -4,6 +4,7 @@ import {
4
4
  createContext,
5
5
  useContext,
6
6
  useMemo,
7
+ useState,
7
8
  type ComponentPropsWithRef,
8
9
  type ReactNode,
9
10
  } from "react";
@@ -167,10 +168,28 @@ export function StructuredField({ className, name, children, ...props }: Structu
167
168
  const state = stateOf(name);
168
169
  const raw = value[name];
169
170
 
171
+ // "A token arrived" and "this field is final" are different facts, and in a
172
+ // streamed object they look identical. The settle makes that distinction
173
+ // visible — so it plays on the transition into settled, and never on a field
174
+ // that was already final when the component mounted.
175
+ //
176
+ // State adjusted during render rather than tracked in a ref: reading a ref
177
+ // while rendering is unsafe under concurrent rendering, and an effect would
178
+ // paint the plain value first and animate a frame later. React re-renders
179
+ // immediately here and discards the intermediate pass.
180
+ const [seenState, setSeenState] = useState<FieldState>(state);
181
+ const [hasSettled, setHasSettled] = useState(false);
182
+
183
+ if (seenState !== state) {
184
+ setSeenState(state);
185
+ if (state === "settled" && seenState !== "settled") setHasSettled(true);
186
+ }
187
+
170
188
  return (
171
189
  <div
172
190
  data-slot="structured-field"
173
191
  data-state={state}
192
+ data-settled={hasSettled || undefined}
174
193
  data-field={name}
175
194
  className={cn("flex flex-col gap-1", className)}
176
195
  {...props}
@@ -195,7 +214,12 @@ export function StructuredField({ className, name, children, ...props }: Structu
195
214
  className="block h-4 w-24 animate-pulse-soft rounded bg-muted"
196
215
  />
197
216
  ) : (
198
- (children ?? formatValue(raw, name))
217
+ <span
218
+ data-slot="structured-field-value"
219
+ className={cn(hasSettled && "inline-block animate-settle")}
220
+ >
221
+ {children ?? formatValue(raw, name)}
222
+ </span>
199
223
  )}
200
224
  </dd>
201
225
  </div>
@@ -0,0 +1,256 @@
1
+ import { diffLines, diffWordsWithSpace } from "diff";
2
+
3
+ /**
4
+ * Turning two versions of a file into rows a diff can render.
5
+ *
6
+ * The algorithm is not the interesting part and is not reimplemented here —
7
+ * jsdiff does Myers properly, is BSD-licensed, has no dependencies of its own,
8
+ * and getting an O(ND) diff subtly wrong is a poor use of anybody's afternoon.
9
+ * What every packaged *viewer* welds on is a styling strategy: emotion in
10
+ * react-diff-viewer-continued, HTML strings and a stylesheet in diff2html. That
11
+ * is what cannot be reached by design tokens, and it is why this exists.
12
+ *
13
+ * Everything below is pure, so the grouping, the context collapsing and the
14
+ * word-level pairing can be tested without rendering anything.
15
+ */
16
+
17
+ export type RowKind = "context" | "added" | "removed";
18
+
19
+ export interface WordSegment {
20
+ text: string;
21
+ changed: boolean;
22
+ }
23
+
24
+ export interface DiffRow {
25
+ kind: RowKind;
26
+ /** 1-based line number in the original. Absent on an added line. */
27
+ before?: number;
28
+ /** 1-based line number in the result. Absent on a removed line. */
29
+ after?: number;
30
+ content: string;
31
+ /**
32
+ * Word-level split, present only where a removed line pairs with an added
33
+ * one. Without a pair there is nothing to compare against, and highlighting
34
+ * the whole line as "changed" would be noise.
35
+ */
36
+ segments?: WordSegment[];
37
+ }
38
+
39
+ export interface DiffHunk {
40
+ id: string;
41
+ rows: DiffRow[];
42
+ /** Context lines hidden before this hunk, if any. */
43
+ skippedBefore: number;
44
+ }
45
+
46
+ export interface BuildDiffOptions {
47
+ /** Unchanged lines kept either side of a change. */
48
+ context?: number;
49
+ /**
50
+ * Compare word by word inside a changed line. Off for very large diffs,
51
+ * where the extra pass costs more than it explains.
52
+ */
53
+ words?: boolean;
54
+ }
55
+
56
+ function splitLines(text: string): string[] {
57
+ const lines = text.split("\n");
58
+ // A trailing newline produces a final empty element that is not a line.
59
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
60
+ return lines;
61
+ }
62
+
63
+ /**
64
+ * Gives both sides a trailing newline before they are compared.
65
+ *
66
+ * jsdiff's line tokens carry their own newline, so a final line written as `a`
67
+ * and one written as `a\n` are different tokens. Appending a line to a file
68
+ * that did not end in a newline would otherwise be reported as the last line
69
+ * being removed and re-added — a change the author did not make, sitting on
70
+ * top of the one they did.
71
+ */
72
+ function withTrailingNewline(text: string): string {
73
+ return text.endsWith("\n") ? text : `${text}\n`;
74
+ }
75
+
76
+ /**
77
+ * Pairs removed lines with added ones inside a single change block.
78
+ *
79
+ * jsdiff reports a change as a run of removals followed by a run of additions.
80
+ * Comparing the first removal against the first addition is what turns "this
81
+ * line went away and another arrived" into "this word changed" — but only while
82
+ * the runs line up. Beyond the shorter run there is no counterpart, and
83
+ * inventing one produces confident nonsense.
84
+ */
85
+ function pairWords(removed: DiffRow[], added: DiffRow[]): void {
86
+ const pairs = Math.min(removed.length, added.length);
87
+
88
+ for (let index = 0; index < pairs; index += 1) {
89
+ const from = removed[index];
90
+ const to = added[index];
91
+ if (!from || !to) continue;
92
+
93
+ const parts = diffWordsWithSpace(from.content, to.content);
94
+
95
+ from.segments = parts
96
+ .filter((part) => !part.added)
97
+ .map((part) => ({ text: part.value, changed: Boolean(part.removed) }));
98
+
99
+ to.segments = parts
100
+ .filter((part) => !part.removed)
101
+ .map((part) => ({ text: part.value, changed: Boolean(part.added) }));
102
+ }
103
+ }
104
+
105
+ export function buildDiff(
106
+ before: string,
107
+ after: string,
108
+ options: BuildDiffOptions = {},
109
+ ): DiffHunk[] {
110
+ const { context = 3, words = true } = options;
111
+
112
+ const changes = diffLines(withTrailingNewline(before), withTrailingNewline(after));
113
+ const rows: DiffRow[] = [];
114
+ let beforeLine = 1;
115
+ let afterLine = 1;
116
+
117
+ // Flat rows first. Grouping into hunks is a separate concern and mixing the
118
+ // two is how these implementations become unreadable.
119
+ let pendingRemoved: DiffRow[] = [];
120
+ let pendingAdded: DiffRow[] = [];
121
+
122
+ const flushPair = () => {
123
+ if (words && pendingRemoved.length > 0 && pendingAdded.length > 0) {
124
+ pairWords(pendingRemoved, pendingAdded);
125
+ }
126
+ pendingRemoved = [];
127
+ pendingAdded = [];
128
+ };
129
+
130
+ for (const change of changes) {
131
+ const lines = splitLines(change.value);
132
+
133
+ if (change.added) {
134
+ for (const content of lines) {
135
+ const row: DiffRow = { kind: "added", after: afterLine, content };
136
+ afterLine += 1;
137
+ rows.push(row);
138
+ pendingAdded.push(row);
139
+ }
140
+ continue;
141
+ }
142
+
143
+ if (change.removed) {
144
+ // A removal run that follows an addition run starts a new pairing.
145
+ if (pendingAdded.length > 0) flushPair();
146
+ for (const content of lines) {
147
+ const row: DiffRow = { kind: "removed", before: beforeLine, content };
148
+ beforeLine += 1;
149
+ rows.push(row);
150
+ pendingRemoved.push(row);
151
+ }
152
+ continue;
153
+ }
154
+
155
+ flushPair();
156
+ for (const content of lines) {
157
+ rows.push({ kind: "context", before: beforeLine, after: afterLine, content });
158
+ beforeLine += 1;
159
+ afterLine += 1;
160
+ }
161
+ }
162
+
163
+ flushPair();
164
+
165
+ return groupIntoHunks(rows, context);
166
+ }
167
+
168
+ /**
169
+ * Groups rows into hunks, dropping context beyond `context` lines.
170
+ *
171
+ * A file with one changed line is otherwise thousands of rows of identical
172
+ * text, and the reader has to find the change in it.
173
+ */
174
+ export function groupIntoHunks(rows: DiffRow[], context: number): DiffHunk[] {
175
+ const changedIndexes = rows
176
+ .map((row, index) => (row.kind === "context" ? -1 : index))
177
+ .filter((index) => index >= 0);
178
+
179
+ if (changedIndexes.length === 0) return [];
180
+
181
+ // Ranges of rows to keep, then merged where their context overlaps —
182
+ // otherwise two nearby changes produce two hunks separated by nothing.
183
+ const ranges: [number, number][] = [];
184
+ for (const index of changedIndexes) {
185
+ const start = Math.max(0, index - context);
186
+ const end = Math.min(rows.length - 1, index + context);
187
+ const last = ranges[ranges.length - 1];
188
+ if (last && start <= last[1] + 1) last[1] = Math.max(last[1], end);
189
+ else ranges.push([start, end]);
190
+ }
191
+
192
+ let previousEnd = -1;
193
+ return ranges.map(([start, end], index) => {
194
+ const skippedBefore = start - previousEnd - 1;
195
+ previousEnd = end;
196
+ return {
197
+ id: `hunk-${String(index)}`,
198
+ rows: rows.slice(start, end + 1),
199
+ skippedBefore: Math.max(0, skippedBefore),
200
+ };
201
+ });
202
+ }
203
+
204
+ /** Row pairs for a side-by-side view, aligning removals against additions. */
205
+ export function toSplitRows(
206
+ rows: DiffRow[],
207
+ ): { left: DiffRow | null; right: DiffRow | null }[] {
208
+ const pairs: { left: DiffRow | null; right: DiffRow | null }[] = [];
209
+ let index = 0;
210
+
211
+ while (index < rows.length) {
212
+ const row = rows[index];
213
+ if (!row) break;
214
+
215
+ if (row.kind === "context") {
216
+ pairs.push({ left: row, right: row });
217
+ index += 1;
218
+ continue;
219
+ }
220
+
221
+ // Take the whole removal run and the whole addition run that follows, then
222
+ // lay them alongside each other. Emitting them in document order instead
223
+ // would put every removal above every addition, which is the unified view
224
+ // wearing a two-column costume.
225
+ const removed: DiffRow[] = [];
226
+ while (rows[index]?.kind === "removed") {
227
+ removed.push(rows[index] as DiffRow);
228
+ index += 1;
229
+ }
230
+ const added: DiffRow[] = [];
231
+ while (rows[index]?.kind === "added") {
232
+ added.push(rows[index] as DiffRow);
233
+ index += 1;
234
+ }
235
+
236
+ const height = Math.max(removed.length, added.length);
237
+ for (let offset = 0; offset < height; offset += 1) {
238
+ pairs.push({ left: removed[offset] ?? null, right: added[offset] ?? null });
239
+ }
240
+ }
241
+
242
+ return pairs;
243
+ }
244
+
245
+ /** Counts, for a summary that says what the diff does before it is read. */
246
+ export function countChanges(hunks: DiffHunk[]): { added: number; removed: number } {
247
+ let added = 0;
248
+ let removed = 0;
249
+ for (const hunk of hunks) {
250
+ for (const row of hunk.rows) {
251
+ if (row.kind === "added") added += 1;
252
+ if (row.kind === "removed") removed += 1;
253
+ }
254
+ }
255
+ return { added, removed };
256
+ }