@deepcitation/deepcitation-js 1.1.23 → 1.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/react/CitationComponent.js +48 -22
- package/lib/react/icons.d.ts +4 -0
- package/lib/react/icons.js +4 -1
- package/lib/react/useSmartDiff.js +3 -3
- package/lib/react/utils.d.ts +1 -0
- package/lib/react/utils.js +6 -0
- package/lib/utils/diff.d.ts +60 -0
- package/lib/utils/diff.js +414 -0
- package/package.json +2 -7
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { forwardRef, memo, useCallback, useEffect, useMemo, useState, } from "react";
|
|
3
3
|
import { createPortal } from "react-dom";
|
|
4
|
-
import { CheckIcon, WarningIcon } from "./icons.js";
|
|
4
|
+
import { CheckIcon, SpinnerIcon, WarningIcon } from "./icons.js";
|
|
5
5
|
import { Popover, PopoverContent, PopoverTrigger } from "./Popover.js";
|
|
6
|
-
import { generateCitationInstanceId, generateCitationKey, getCitationDisplayText, } from "./utils.js";
|
|
6
|
+
import { cn, generateCitationInstanceId, generateCitationKey, getCitationDisplayText, } from "./utils.js";
|
|
7
7
|
import { useSmartDiff } from "./useSmartDiff.js";
|
|
8
|
-
// =============================================================================
|
|
9
|
-
// UTILITY FUNCTIONS
|
|
10
|
-
// =============================================================================
|
|
11
|
-
function cn(...classes) {
|
|
12
|
-
return classes.filter(Boolean).join(" ");
|
|
13
|
-
}
|
|
14
8
|
function getStatusLabel(status) {
|
|
15
9
|
if (status.isVerified && !status.isPartialMatch)
|
|
16
10
|
return "Verified";
|
|
@@ -30,7 +24,12 @@ function getStatusFromVerification(verification) {
|
|
|
30
24
|
const status = verification?.status;
|
|
31
25
|
// No verification or no status = pending
|
|
32
26
|
if (!verification || !status) {
|
|
33
|
-
return {
|
|
27
|
+
return {
|
|
28
|
+
isVerified: false,
|
|
29
|
+
isMiss: false,
|
|
30
|
+
isPartialMatch: false,
|
|
31
|
+
isPending: true,
|
|
32
|
+
};
|
|
34
33
|
}
|
|
35
34
|
const isMiss = status === "not_found";
|
|
36
35
|
const isPending = status === "pending" || status === "loading";
|
|
@@ -74,14 +73,12 @@ function ImageOverlay({ src, alt, onClose }) {
|
|
|
74
73
|
//
|
|
75
74
|
// Use `renderIndicator` prop to customize. Use `variant="indicator"` to show only the icon.
|
|
76
75
|
// =============================================================================
|
|
77
|
-
/** Spinner component for loading/pending state */
|
|
78
|
-
const Spinner = ({ className }) => (_jsxs("svg", { className: cn("animate-spin", className), xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", width: "12", height: "12", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }));
|
|
79
76
|
/** Verified indicator - green checkmark for exact matches */
|
|
80
77
|
const VerifiedIndicator = () => (_jsx("span", { className: "inline-flex relative ml-0.5 text-green-600 dark:text-green-500", "aria-hidden": "true", children: _jsx(CheckIcon, {}) }));
|
|
81
78
|
/** Partial match indicator - amber checkmark for partial/relocated matches */
|
|
82
79
|
const PartialIndicator = () => (_jsx("span", { className: "inline-flex relative ml-0.5 text-amber-600 dark:text-amber-500", "aria-hidden": "true", children: _jsx(CheckIcon, {}) }));
|
|
83
80
|
/** Pending indicator - spinner for loading state */
|
|
84
|
-
const PendingIndicator = () => (_jsx("span", { className: "inline-flex ml-1 text-gray-400 dark:text-gray-500", "aria-hidden": "true", children: _jsx(
|
|
81
|
+
const PendingIndicator = () => (_jsx("span", { className: "inline-flex ml-1 text-gray-400 dark:text-gray-500", "aria-hidden": "true", children: _jsx(SpinnerIcon, {}) }));
|
|
85
82
|
/** Miss indicator - red warning triangle for not found */
|
|
86
83
|
const MissIndicator = () => (_jsx("span", { className: "inline-flex relative ml-0.5 text-red-500 dark:text-red-400", "aria-hidden": "true", children: _jsx(WarningIcon, {}) }));
|
|
87
84
|
function DefaultPopoverContent({ citation, verification, status, onImageClick, }) {
|
|
@@ -101,7 +98,9 @@ function DefaultPopoverContent({ citation, verification, status, onImageClick, }
|
|
|
101
98
|
const pageNumber = verification?.verifiedPageNumber;
|
|
102
99
|
if (!hasSnippet && !statusLabel)
|
|
103
100
|
return null;
|
|
104
|
-
return (_jsxs("div", { className: "p-3 flex flex-col gap-2 min-w-[200px] max-w-[400px]", children: [statusLabel && (_jsx("span", { className: cn("text-xs font-medium", status.isVerified &&
|
|
101
|
+
return (_jsxs("div", { className: "p-3 flex flex-col gap-2 min-w-[200px] max-w-[400px]", children: [statusLabel && (_jsx("span", { className: cn("text-xs font-medium", status.isVerified &&
|
|
102
|
+
!status.isPartialMatch &&
|
|
103
|
+
"text-green-600 dark:text-green-500", status.isPartialMatch && "text-amber-600 dark:text-amber-500", status.isMiss && "text-red-600 dark:text-red-500", status.isPending && "text-gray-500 dark:text-gray-400"), children: statusLabel })), hasSnippet && (_jsxs("span", { className: "text-sm text-gray-700 dark:text-gray-300 italic", children: ["\"", verification.verifiedMatchSnippet, "\""] })), pageNumber && pageNumber > 0 && (_jsxs("span", { className: "text-xs text-gray-500 dark:text-gray-400", children: ["Page ", pageNumber] })), (isMiss || isPartialMatch) && (_jsx(DiffDetails, { citation: citation, verification: verification, status: status }))] }));
|
|
105
104
|
}
|
|
106
105
|
// =============================================================================
|
|
107
106
|
// DIFF DETAILS COMPONENT
|
|
@@ -128,10 +127,16 @@ function DiffDetails({ citation, verification, status, }) {
|
|
|
128
127
|
const pageDiffers = expectedPage != null && actualPage != null && expectedPage !== actualPage;
|
|
129
128
|
// For "not_found" status, show expected text and "Not found" message
|
|
130
129
|
if (isMiss) {
|
|
131
|
-
return (_jsxs("div", { className: "mt-2 pt-2 border-t border-gray-200 dark:border-gray-700 text-xs space-y-2", children: [expectedText && (_jsxs("div", { children: [_jsx("span", { className: "text-gray-500 dark:text-gray-400 font-medium uppercase text-[10px]", children: "Expected" }), _jsx("p", { className: "mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded font-mono text-[11px] break-words text-red-600 dark:text-red-400 line-through opacity-70", children: expectedText.length > 100
|
|
130
|
+
return (_jsxs("div", { className: "mt-2 pt-2 border-t border-gray-200 dark:border-gray-700 text-xs space-y-2", children: [expectedText && (_jsxs("div", { children: [_jsx("span", { className: "text-gray-500 dark:text-gray-400 font-medium uppercase text-[10px]", children: "Expected" }), _jsx("p", { className: "mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded font-mono text-[11px] break-words text-red-600 dark:text-red-400 line-through opacity-70", children: expectedText.length > 100
|
|
131
|
+
? expectedText.slice(0, 100) + "…"
|
|
132
|
+
: expectedText })] })), _jsxs("div", { children: [_jsx("span", { className: "text-gray-500 dark:text-gray-400 font-medium uppercase text-[10px]", children: "Found" }), _jsx("p", { className: "mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded font-mono text-[11px] text-amber-600 dark:text-amber-500 italic", children: "Not found in source" })] })] }));
|
|
132
133
|
}
|
|
133
134
|
// For partial matches, show word-level diff
|
|
134
|
-
return (_jsxs("div", { className: "mt-2 pt-2 border-t border-gray-200 dark:border-gray-700 text-xs space-y-2", children: [expectedText && actualText && hasDiff ? (_jsxs("div", { children: [_jsx("span", { className: "text-gray-500 dark:text-gray-400 font-medium uppercase text-[10px]", children: "Diff" }), _jsx("div", { className: "mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded font-mono text-[11px] break-words text-gray-700 dark:text-gray-300", children: isHighVariance ? (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { children: [
|
|
135
|
+
return (_jsxs("div", { className: "mt-2 pt-2 border-t border-gray-200 dark:border-gray-700 text-xs space-y-2", children: [expectedText && actualText && hasDiff ? (_jsxs("div", { children: [_jsx("span", { className: "text-gray-500 dark:text-gray-400 font-medium uppercase text-[10px]", children: "Diff" }), _jsx("div", { className: "mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded font-mono text-[11px] break-words text-gray-700 dark:text-gray-300", children: isHighVariance ? (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { children: [_jsxs("span", { className: "text-gray-500 dark:text-gray-400 text-[10px]", children: ["Expected:", " "] }), _jsx("span", { className: "text-red-600 dark:text-red-400 line-through opacity-70", children: expectedText.length > 100
|
|
136
|
+
? expectedText.slice(0, 100) + "…"
|
|
137
|
+
: expectedText })] }), _jsxs("div", { children: [_jsxs("span", { className: "text-gray-500 dark:text-gray-400 text-[10px]", children: ["Found:", " "] }), _jsx("span", { className: "text-green-600 dark:text-green-400", children: actualText.length > 100
|
|
138
|
+
? actualText.slice(0, 100) + "…"
|
|
139
|
+
: actualText })] })] })) : (
|
|
135
140
|
// Inline word-level diff
|
|
136
141
|
diffResult.map((block, blockIndex) => (_jsx("span", { children: block.parts.map((part, partIndex) => {
|
|
137
142
|
const key = `p-${blockIndex}-${partIndex}`;
|
|
@@ -145,7 +150,9 @@ function DiffDetails({ citation, verification, status, }) {
|
|
|
145
150
|
return _jsx("span", { children: part.value }, key);
|
|
146
151
|
}) }, `block-${blockIndex}`)))) })] })) : expectedText && !hasDiff ? (
|
|
147
152
|
// Text matches exactly (partial match is due to location difference)
|
|
148
|
-
_jsxs("div", { children: [_jsx("span", { className: "text-gray-500 dark:text-gray-400 font-medium uppercase text-[10px]", children: "Text" }), _jsx("p", { className: "mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded font-mono text-[11px] break-words text-gray-700 dark:text-gray-300", children: expectedText.length > 100
|
|
153
|
+
_jsxs("div", { children: [_jsx("span", { className: "text-gray-500 dark:text-gray-400 font-medium uppercase text-[10px]", children: "Text" }), _jsx("p", { className: "mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded font-mono text-[11px] break-words text-gray-700 dark:text-gray-300", children: expectedText.length > 100
|
|
154
|
+
? expectedText.slice(0, 100) + "…"
|
|
155
|
+
: expectedText })] })) : null, pageDiffers && (_jsxs("div", { children: [_jsx("span", { className: "text-gray-500 dark:text-gray-400 font-medium uppercase text-[10px]", children: "Page" }), _jsxs("p", { className: "mt-1 font-mono text-[11px] text-gray-700 dark:text-gray-300", children: [_jsx("span", { className: "text-red-600 dark:text-red-400 line-through opacity-70", children: expectedPage }), " → ", actualPage] })] })), lineIdDiffers && (_jsxs("div", { children: [_jsx("span", { className: "text-gray-500 dark:text-gray-400 font-medium uppercase text-[10px]", children: "Line" }), _jsxs("p", { className: "mt-1 font-mono text-[11px] text-gray-700 dark:text-gray-300", children: [_jsx("span", { className: "text-red-600 dark:text-red-400 line-through opacity-70", children: expectedLineIds?.join(", ") }), " → ", actualLineIds?.join(", ")] })] }))] }));
|
|
149
156
|
}
|
|
150
157
|
// =============================================================================
|
|
151
158
|
// MAIN COMPONENT
|
|
@@ -193,7 +200,8 @@ export const CitationComponent = forwardRef(({ citation, children, className, hi
|
|
|
193
200
|
if (typeof actions.setImageExpanded === "string") {
|
|
194
201
|
setExpandedImageSrc(actions.setImageExpanded);
|
|
195
202
|
}
|
|
196
|
-
else if (actions.setImageExpanded === true &&
|
|
203
|
+
else if (actions.setImageExpanded === true &&
|
|
204
|
+
verification?.verificationImageBase64) {
|
|
197
205
|
setExpandedImageSrc(verification.verificationImageBase64);
|
|
198
206
|
}
|
|
199
207
|
else if (actions.setImageExpanded === false) {
|
|
@@ -240,14 +248,26 @@ export const CitationComponent = forwardRef(({ citation, children, className, hi
|
|
|
240
248
|
behaviorConfig.onHover.onEnter(getBehaviorContext());
|
|
241
249
|
}
|
|
242
250
|
eventHandlers?.onMouseEnter?.(citation, citationKey);
|
|
243
|
-
}, [
|
|
251
|
+
}, [
|
|
252
|
+
eventHandlers,
|
|
253
|
+
behaviorConfig,
|
|
254
|
+
citation,
|
|
255
|
+
citationKey,
|
|
256
|
+
getBehaviorContext,
|
|
257
|
+
]);
|
|
244
258
|
const handleMouseLeave = useCallback(() => {
|
|
245
259
|
setIsHovering(false);
|
|
246
260
|
if (behaviorConfig?.onHover?.onLeave) {
|
|
247
261
|
behaviorConfig.onHover.onLeave(getBehaviorContext());
|
|
248
262
|
}
|
|
249
263
|
eventHandlers?.onMouseLeave?.(citation, citationKey);
|
|
250
|
-
}, [
|
|
264
|
+
}, [
|
|
265
|
+
eventHandlers,
|
|
266
|
+
behaviorConfig,
|
|
267
|
+
citation,
|
|
268
|
+
citationKey,
|
|
269
|
+
getBehaviorContext,
|
|
270
|
+
]);
|
|
251
271
|
// Touch handler for mobile
|
|
252
272
|
const handleTouchEnd = useCallback((e) => {
|
|
253
273
|
if (isMobile) {
|
|
@@ -257,13 +277,18 @@ export const CitationComponent = forwardRef(({ citation, children, className, hi
|
|
|
257
277
|
}
|
|
258
278
|
}, [eventHandlers, citation, citationKey, isMobile]);
|
|
259
279
|
// Early return for miss with fallback display
|
|
260
|
-
if (fallbackDisplay !== null &&
|
|
280
|
+
if (fallbackDisplay !== null &&
|
|
281
|
+
fallbackDisplay !== undefined &&
|
|
282
|
+
!hideKeySpan &&
|
|
283
|
+
isMiss) {
|
|
261
284
|
return (_jsx("span", { className: cn("text-gray-400 dark:text-gray-500", className), children: fallbackDisplay }));
|
|
262
285
|
}
|
|
263
286
|
// Status classes for text styling
|
|
264
287
|
const statusClasses = cn(
|
|
265
288
|
// Found status (text color) - verified or partial match
|
|
266
|
-
(isVerified || isPartialMatch) &&
|
|
289
|
+
(isVerified || isPartialMatch) &&
|
|
290
|
+
variant === "brackets" &&
|
|
291
|
+
"text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline", isMiss && "opacity-70 line-through text-gray-400 dark:text-gray-500", isPending && "text-gray-500 dark:text-gray-400");
|
|
267
292
|
// Render indicator based on status priority:
|
|
268
293
|
// 1. Custom renderIndicator (if provided)
|
|
269
294
|
// 2. Pending → Spinner
|
|
@@ -310,7 +335,8 @@ export const CitationComponent = forwardRef(({ citation, children, className, hi
|
|
|
310
335
|
const isPopoverHidden = popoverPosition === "hidden";
|
|
311
336
|
const shouldShowPopover = !isPopoverHidden &&
|
|
312
337
|
verification &&
|
|
313
|
-
(verification.verificationImageBase64 ||
|
|
338
|
+
(verification.verificationImageBase64 ||
|
|
339
|
+
verification.verifiedMatchSnippet);
|
|
314
340
|
const hasImage = !!verification?.verificationImageBase64;
|
|
315
341
|
// Image overlay
|
|
316
342
|
const imageOverlay = expandedImageSrc ? (_jsx(ImageOverlay, { src: expandedImageSrc, alt: "Citation verification - full size", onClose: () => setExpandedImageSrc(null) })) : null;
|
package/lib/react/icons.d.ts
CHANGED
|
@@ -6,3 +6,7 @@ export declare const CheckIcon: () => import("react/jsx-runtime").JSX.Element;
|
|
|
6
6
|
* Warning icon SVG (no dependencies)
|
|
7
7
|
*/
|
|
8
8
|
export declare const WarningIcon: () => import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
/** Spinner component for loading/pending state */
|
|
10
|
+
export declare const SpinnerIcon: ({ className }: {
|
|
11
|
+
className?: string;
|
|
12
|
+
}) => import("react/jsx-runtime").JSX.Element;
|
package/lib/react/icons.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "./utils.js";
|
|
2
3
|
/**
|
|
3
4
|
* Check icon SVG (no dependencies)
|
|
4
5
|
*/
|
|
@@ -7,3 +8,5 @@ export const CheckIcon = () => (_jsx("svg", { className: "dc-check-icon", viewBo
|
|
|
7
8
|
* Warning icon SVG (no dependencies)
|
|
8
9
|
*/
|
|
9
10
|
export const WarningIcon = () => (_jsx("svg", { className: "dc-status-icon", viewBox: "0 0 256 256", fill: "currentColor", "aria-hidden": "true", children: _jsx("path", { d: "M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM120,104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm8,88a12,12,0,1,1,12-12A12,12,0,0,1,128,192Z" }) }));
|
|
11
|
+
/** Spinner component for loading/pending state */
|
|
12
|
+
export const SpinnerIcon = ({ className }) => (_jsxs("svg", { className: cn("animate-spin", className), xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", width: "12", height: "12", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { diffLines, diffWordsWithSpace } from "../utils/diff.js";
|
|
2
2
|
import { useMemo } from "react";
|
|
3
3
|
export const useSmartDiff = (expected = "", actual = "") => {
|
|
4
4
|
return useMemo(() => {
|
|
@@ -8,7 +8,7 @@ export const useSmartDiff = (expected = "", actual = "") => {
|
|
|
8
8
|
// 2. First Pass: Diff by LINES.
|
|
9
9
|
// This isolates the "extra line" issue. The extra line becomes one "added" chunk,
|
|
10
10
|
// and it prevents the tokenizer from getting confused on the rest of the text.
|
|
11
|
-
const lineDiffs =
|
|
11
|
+
const lineDiffs = diffLines(cleanExpected, cleanActual);
|
|
12
12
|
// 3. Second Pass: Process the line results to find "Modifications"
|
|
13
13
|
const processedDiffs = [];
|
|
14
14
|
let hasDiff = false;
|
|
@@ -21,7 +21,7 @@ export const useSmartDiff = (expected = "", actual = "") => {
|
|
|
21
21
|
// it means this specific line changed. We should DIFF WORDS inside this line.
|
|
22
22
|
if (part.removed && nextPart && nextPart.added) {
|
|
23
23
|
// Run word diff ONLY on this pair of lines
|
|
24
|
-
const wordDiffs =
|
|
24
|
+
const wordDiffs = diffWordsWithSpace(part.value, nextPart.value);
|
|
25
25
|
processedDiffs.push({
|
|
26
26
|
type: "modified",
|
|
27
27
|
parts: wordDiffs,
|
package/lib/react/utils.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Citation } from "../types/citation.js";
|
|
2
2
|
import type { Verification } from "../types/verification.js";
|
|
3
|
+
export declare function cn(...classes: (string | undefined | null | false)[]): string;
|
|
3
4
|
/**
|
|
4
5
|
* Generates a unique, deterministic key for a citation based on its content.
|
|
5
6
|
* @param citation - The citation to generate a key for
|
package/lib/react/utils.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { sha1Hash } from "../utils/sha.js";
|
|
2
2
|
import { getCitationPageNumber } from "../parsing/normalizeCitation.js";
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// UTILITY FUNCTIONS
|
|
5
|
+
// =============================================================================
|
|
6
|
+
export function cn(...classes) {
|
|
7
|
+
return classes.filter(Boolean).join(" ");
|
|
8
|
+
}
|
|
3
9
|
/**
|
|
4
10
|
* Generates a unique, deterministic key for a citation based on its content.
|
|
5
11
|
* @param citation - The citation to generate a key for
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom diff implementation to replace the 'diff' npm package.
|
|
3
|
+
* This avoids dependency issues in Firebase Functions environments.
|
|
4
|
+
*
|
|
5
|
+
* Implements a Myers diff algorithm with optimizations inspired by jsdiff.
|
|
6
|
+
* @see https://github.com/kpdecker/jsdiff
|
|
7
|
+
*
|
|
8
|
+
* ---
|
|
9
|
+
*
|
|
10
|
+
* BSD 3-Clause License
|
|
11
|
+
*
|
|
12
|
+
* Copyright (c) 2009-2015, Kevin Decker <kpdecker@gmail.com>
|
|
13
|
+
* All rights reserved.
|
|
14
|
+
*
|
|
15
|
+
* Redistribution and use in source and binary forms, with or without
|
|
16
|
+
* modification, are permitted provided that the following conditions are met:
|
|
17
|
+
*
|
|
18
|
+
* 1. Redistributions of source code must retain the above copyright notice, this
|
|
19
|
+
* list of conditions and the following disclaimer.
|
|
20
|
+
*
|
|
21
|
+
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
22
|
+
* this list of conditions and the following disclaimer in the documentation
|
|
23
|
+
* and/or other materials provided with the distribution.
|
|
24
|
+
*
|
|
25
|
+
* 3. Neither the name of the copyright holder nor the names of its
|
|
26
|
+
* contributors may be used to endorse or promote products derived from
|
|
27
|
+
* this software without specific prior written permission.
|
|
28
|
+
*
|
|
29
|
+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
30
|
+
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
31
|
+
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
32
|
+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
33
|
+
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
34
|
+
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
35
|
+
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
36
|
+
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
37
|
+
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
38
|
+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
39
|
+
*/
|
|
40
|
+
export interface Change {
|
|
41
|
+
value: string;
|
|
42
|
+
added?: boolean;
|
|
43
|
+
removed?: boolean;
|
|
44
|
+
count?: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Compare two strings line by line.
|
|
48
|
+
* Similar to Diff.diffLines from the 'diff' package.
|
|
49
|
+
*/
|
|
50
|
+
export declare function diffLines(oldStr: string, newStr: string): Change[];
|
|
51
|
+
/**
|
|
52
|
+
* Compare two strings word by word, preserving whitespace.
|
|
53
|
+
* Similar to Diff.diffWordsWithSpace from the 'diff' package.
|
|
54
|
+
*
|
|
55
|
+
* Features matching jsdiff:
|
|
56
|
+
* - Extended Unicode word character support
|
|
57
|
+
* - Proper tokenization (words, whitespace runs, single punctuation)
|
|
58
|
+
* - Whitespace deduplication in consecutive changes
|
|
59
|
+
*/
|
|
60
|
+
export declare function diffWordsWithSpace(oldStr: string, newStr: string): Change[];
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom diff implementation to replace the 'diff' npm package.
|
|
3
|
+
* This avoids dependency issues in Firebase Functions environments.
|
|
4
|
+
*
|
|
5
|
+
* Implements a Myers diff algorithm with optimizations inspired by jsdiff.
|
|
6
|
+
* @see https://github.com/kpdecker/jsdiff
|
|
7
|
+
*
|
|
8
|
+
* ---
|
|
9
|
+
*
|
|
10
|
+
* BSD 3-Clause License
|
|
11
|
+
*
|
|
12
|
+
* Copyright (c) 2009-2015, Kevin Decker <kpdecker@gmail.com>
|
|
13
|
+
* All rights reserved.
|
|
14
|
+
*
|
|
15
|
+
* Redistribution and use in source and binary forms, with or without
|
|
16
|
+
* modification, are permitted provided that the following conditions are met:
|
|
17
|
+
*
|
|
18
|
+
* 1. Redistributions of source code must retain the above copyright notice, this
|
|
19
|
+
* list of conditions and the following disclaimer.
|
|
20
|
+
*
|
|
21
|
+
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
22
|
+
* this list of conditions and the following disclaimer in the documentation
|
|
23
|
+
* and/or other materials provided with the distribution.
|
|
24
|
+
*
|
|
25
|
+
* 3. Neither the name of the copyright holder nor the names of its
|
|
26
|
+
* contributors may be used to endorse or promote products derived from
|
|
27
|
+
* this software without specific prior written permission.
|
|
28
|
+
*
|
|
29
|
+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
30
|
+
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
31
|
+
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
32
|
+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
33
|
+
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
34
|
+
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
35
|
+
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
36
|
+
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
37
|
+
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
38
|
+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Myers diff algorithm with diagonal pruning optimization.
|
|
42
|
+
* This reduces complexity from O(n+d²) to O(n+d) for common cases like appending text.
|
|
43
|
+
*
|
|
44
|
+
* @see https://blog.jcoglan.com/2017/02/12/the-myers-diff-algorithm-part-1/
|
|
45
|
+
*/
|
|
46
|
+
function computeDiff(oldTokens, newTokens, equals = (a, b) => a === b) {
|
|
47
|
+
const oldLen = oldTokens.length;
|
|
48
|
+
const newLen = newTokens.length;
|
|
49
|
+
// Handle edge cases
|
|
50
|
+
if (oldLen === 0 && newLen === 0) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
// Quick path for completely new content
|
|
54
|
+
if (oldLen === 0) {
|
|
55
|
+
return [{ value: newTokens.join(""), added: true, count: newTokens.length }];
|
|
56
|
+
}
|
|
57
|
+
// Quick path for completely removed content
|
|
58
|
+
if (newLen === 0) {
|
|
59
|
+
return [
|
|
60
|
+
{ value: oldTokens.join(""), removed: true, count: oldTokens.length },
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
// Find common prefix
|
|
64
|
+
let commonPrefixLen = 0;
|
|
65
|
+
while (commonPrefixLen < oldLen &&
|
|
66
|
+
commonPrefixLen < newLen &&
|
|
67
|
+
equals(oldTokens[commonPrefixLen], newTokens[commonPrefixLen])) {
|
|
68
|
+
commonPrefixLen++;
|
|
69
|
+
}
|
|
70
|
+
// Find common suffix (but don't overlap with prefix)
|
|
71
|
+
let commonSuffixLen = 0;
|
|
72
|
+
while (commonSuffixLen < oldLen - commonPrefixLen &&
|
|
73
|
+
commonSuffixLen < newLen - commonPrefixLen &&
|
|
74
|
+
equals(oldTokens[oldLen - 1 - commonSuffixLen], newTokens[newLen - 1 - commonSuffixLen])) {
|
|
75
|
+
commonSuffixLen++;
|
|
76
|
+
}
|
|
77
|
+
// Extract the differing middle portions
|
|
78
|
+
const oldMiddle = oldTokens.slice(commonPrefixLen, oldLen - commonSuffixLen);
|
|
79
|
+
const newMiddle = newTokens.slice(commonPrefixLen, newLen - commonSuffixLen);
|
|
80
|
+
// If middles are empty, we only have common prefix/suffix
|
|
81
|
+
if (oldMiddle.length === 0 && newMiddle.length === 0) {
|
|
82
|
+
return [{ value: oldTokens.join(""), count: oldTokens.length }];
|
|
83
|
+
}
|
|
84
|
+
// Compute diff on the middle portion using Myers algorithm
|
|
85
|
+
const middleDiff = myersDiff(oldMiddle, newMiddle, equals);
|
|
86
|
+
// Build result with prefix, middle diff, and suffix
|
|
87
|
+
const result = [];
|
|
88
|
+
if (commonPrefixLen > 0) {
|
|
89
|
+
result.push({
|
|
90
|
+
value: oldTokens.slice(0, commonPrefixLen).join(""),
|
|
91
|
+
count: commonPrefixLen,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
result.push(...middleDiff);
|
|
95
|
+
if (commonSuffixLen > 0) {
|
|
96
|
+
result.push({
|
|
97
|
+
value: oldTokens.slice(oldLen - commonSuffixLen).join(""),
|
|
98
|
+
count: commonSuffixLen,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return mergeConsecutiveChanges(result);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Myers diff algorithm implementation.
|
|
105
|
+
* Uses the "middle snake" approach for better memory efficiency.
|
|
106
|
+
*/
|
|
107
|
+
function myersDiff(oldTokens, newTokens, equals) {
|
|
108
|
+
const oldLen = oldTokens.length;
|
|
109
|
+
const newLen = newTokens.length;
|
|
110
|
+
const maxD = oldLen + newLen;
|
|
111
|
+
// V array indexed by k = x - y (diagonal)
|
|
112
|
+
// We use an object to handle negative indices
|
|
113
|
+
const v = { 1: 0 };
|
|
114
|
+
// Store the path for backtracking
|
|
115
|
+
const trace = [];
|
|
116
|
+
// Iterate through edit distances
|
|
117
|
+
outer: for (let d = 0; d <= maxD; d++) {
|
|
118
|
+
trace.push({ ...v });
|
|
119
|
+
// Iterate through diagonals
|
|
120
|
+
for (let k = -d; k <= d; k += 2) {
|
|
121
|
+
// Decide whether to go down or right
|
|
122
|
+
let x;
|
|
123
|
+
if (k === -d || (k !== d && v[k - 1] < v[k + 1])) {
|
|
124
|
+
x = v[k + 1]; // Move down (insert)
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
x = v[k - 1] + 1; // Move right (delete)
|
|
128
|
+
}
|
|
129
|
+
let y = x - k;
|
|
130
|
+
// Follow diagonal (matches)
|
|
131
|
+
while (x < oldLen && y < newLen && equals(oldTokens[x], newTokens[y])) {
|
|
132
|
+
x++;
|
|
133
|
+
y++;
|
|
134
|
+
}
|
|
135
|
+
v[k] = x;
|
|
136
|
+
// Check if we've reached the end
|
|
137
|
+
if (x >= oldLen && y >= newLen) {
|
|
138
|
+
break outer;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Backtrack to build the diff
|
|
143
|
+
return backtrack(trace, oldTokens, newTokens);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Backtrack through the trace to build the diff result.
|
|
147
|
+
*/
|
|
148
|
+
function backtrack(trace, oldTokens, newTokens) {
|
|
149
|
+
const changes = [];
|
|
150
|
+
let x = oldTokens.length;
|
|
151
|
+
let y = newTokens.length;
|
|
152
|
+
for (let d = trace.length - 1; d >= 0; d--) {
|
|
153
|
+
const v = trace[d];
|
|
154
|
+
const k = x - y;
|
|
155
|
+
let prevK;
|
|
156
|
+
if (k === -d || (k !== d && v[k - 1] < v[k + 1])) {
|
|
157
|
+
prevK = k + 1;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
prevK = k - 1;
|
|
161
|
+
}
|
|
162
|
+
const prevX = v[prevK] ?? 0;
|
|
163
|
+
const prevY = prevX - prevK;
|
|
164
|
+
// Add diagonal matches (unchanged)
|
|
165
|
+
while (x > prevX && y > prevY) {
|
|
166
|
+
x--;
|
|
167
|
+
y--;
|
|
168
|
+
changes.unshift({ value: oldTokens[x], count: 1 });
|
|
169
|
+
}
|
|
170
|
+
if (d > 0) {
|
|
171
|
+
if (x === prevX) {
|
|
172
|
+
// Insertion (went down)
|
|
173
|
+
y--;
|
|
174
|
+
changes.unshift({ value: newTokens[y], added: true, count: 1 });
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// Deletion (went right)
|
|
178
|
+
x--;
|
|
179
|
+
changes.unshift({ value: oldTokens[x], removed: true, count: 1 });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return changes;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Merge consecutive changes of the same type.
|
|
187
|
+
*/
|
|
188
|
+
function mergeConsecutiveChanges(changes) {
|
|
189
|
+
if (changes.length === 0)
|
|
190
|
+
return [];
|
|
191
|
+
const result = [];
|
|
192
|
+
for (const change of changes) {
|
|
193
|
+
const last = result[result.length - 1];
|
|
194
|
+
if (last &&
|
|
195
|
+
last.added === change.added &&
|
|
196
|
+
last.removed === change.removed) {
|
|
197
|
+
last.value += change.value;
|
|
198
|
+
last.count = (last.count || 1) + (change.count || 1);
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
result.push({ ...change });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Split text into lines, preserving line endings.
|
|
208
|
+
* Handles both Unix (\n) and Windows (\r\n) line endings.
|
|
209
|
+
*/
|
|
210
|
+
function splitLines(text) {
|
|
211
|
+
if (!text)
|
|
212
|
+
return [];
|
|
213
|
+
const lines = [];
|
|
214
|
+
let current = "";
|
|
215
|
+
for (let i = 0; i < text.length; i++) {
|
|
216
|
+
const char = text[i];
|
|
217
|
+
current += char;
|
|
218
|
+
if (char === "\n") {
|
|
219
|
+
lines.push(current);
|
|
220
|
+
current = "";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Don't forget the last line if it doesn't end with newline
|
|
224
|
+
if (current.length > 0) {
|
|
225
|
+
lines.push(current);
|
|
226
|
+
}
|
|
227
|
+
return lines;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Extended word character class - matches jsdiff's extendedWordChars.
|
|
231
|
+
* Includes: a-zA-Z0-9_, soft hyphen, Latin Extended-A/B, IPA Extensions,
|
|
232
|
+
* Spacing Modifier Letters, and Latin Extended Additional.
|
|
233
|
+
*
|
|
234
|
+
* @see https://github.com/kpdecker/jsdiff/blob/master/src/diff/word.ts
|
|
235
|
+
*/
|
|
236
|
+
const EXTENDED_WORD_CHARS = "a-zA-Z0-9_\\u00AD\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02C6\\u02C8-\\u02D7\\u02DE-\\u02FF\\u1E00-\\u1EFF";
|
|
237
|
+
/**
|
|
238
|
+
* Tokenization regex matching jsdiff's approach.
|
|
239
|
+
* Matches: word character runs, whitespace runs, or single non-word chars.
|
|
240
|
+
*/
|
|
241
|
+
const TOKENIZE_REGEX = new RegExp(`[${EXTENDED_WORD_CHARS}]+|\\s+|[^${EXTENDED_WORD_CHARS}]`, "gu");
|
|
242
|
+
/**
|
|
243
|
+
* Split text into tokens using jsdiff's tokenization approach.
|
|
244
|
+
* Each token is one of:
|
|
245
|
+
* - A word (extended word characters)
|
|
246
|
+
* - A whitespace run
|
|
247
|
+
* - A single punctuation/symbol character
|
|
248
|
+
*/
|
|
249
|
+
function tokenizeWords(text) {
|
|
250
|
+
if (!text)
|
|
251
|
+
return [];
|
|
252
|
+
return text.match(TOKENIZE_REGEX) || [];
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Find the longest common prefix between two strings.
|
|
256
|
+
*/
|
|
257
|
+
function longestCommonPrefix(a, b) {
|
|
258
|
+
let i = 0;
|
|
259
|
+
while (i < a.length && i < b.length && a[i] === b[i]) {
|
|
260
|
+
i++;
|
|
261
|
+
}
|
|
262
|
+
return a.slice(0, i);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Find the longest common suffix between two strings.
|
|
266
|
+
*/
|
|
267
|
+
function longestCommonSuffix(a, b) {
|
|
268
|
+
let i = 0;
|
|
269
|
+
while (i < a.length &&
|
|
270
|
+
i < b.length &&
|
|
271
|
+
a[a.length - 1 - i] === b[b.length - 1 - i]) {
|
|
272
|
+
i++;
|
|
273
|
+
}
|
|
274
|
+
return a.slice(a.length - i);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Check if a string is only whitespace.
|
|
278
|
+
*/
|
|
279
|
+
function isWhitespace(str) {
|
|
280
|
+
return /^\s*$/.test(str);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Deduplicate whitespace in change objects.
|
|
284
|
+
* This is a simplified version of jsdiff's dedupeWhitespaceInChangeObjects.
|
|
285
|
+
*
|
|
286
|
+
* Handles three main scenarios:
|
|
287
|
+
* 1. Deletion followed by insertion - extract common leading/trailing whitespace
|
|
288
|
+
* 2. Lone insertion after unchanged - strip duplicate leading whitespace
|
|
289
|
+
* 3. Lone deletion between unchanged - distribute whitespace properly
|
|
290
|
+
*/
|
|
291
|
+
function dedupeWhitespaceInChangeObjects(changes) {
|
|
292
|
+
const result = [];
|
|
293
|
+
for (let i = 0; i < changes.length; i++) {
|
|
294
|
+
const change = changes[i];
|
|
295
|
+
// Scenario 1: Deletion followed by insertion
|
|
296
|
+
if (change.removed && changes[i + 1]?.added) {
|
|
297
|
+
const deletion = change;
|
|
298
|
+
const insertion = changes[i + 1];
|
|
299
|
+
// Find common prefix (must be whitespace)
|
|
300
|
+
const commonPrefix = longestCommonPrefix(deletion.value, insertion.value);
|
|
301
|
+
const wsPrefix = commonPrefix.match(/^\s*/)?.[0] || "";
|
|
302
|
+
// Find common suffix (must be whitespace)
|
|
303
|
+
const delWithoutPrefix = deletion.value.slice(wsPrefix.length);
|
|
304
|
+
const insWithoutPrefix = insertion.value.slice(wsPrefix.length);
|
|
305
|
+
const commonSuffix = longestCommonSuffix(delWithoutPrefix, insWithoutPrefix);
|
|
306
|
+
const wsSuffix = commonSuffix.match(/\s*$/)?.[0] || "";
|
|
307
|
+
// Build the cleaned changes
|
|
308
|
+
if (wsPrefix) {
|
|
309
|
+
result.push({ value: wsPrefix, count: 1 });
|
|
310
|
+
}
|
|
311
|
+
const cleanedDel = deletion.value.slice(wsPrefix.length, deletion.value.length - wsSuffix.length);
|
|
312
|
+
const cleanedIns = insertion.value.slice(wsPrefix.length, insertion.value.length - wsSuffix.length);
|
|
313
|
+
if (cleanedDel) {
|
|
314
|
+
result.push({ value: cleanedDel, removed: true, count: 1 });
|
|
315
|
+
}
|
|
316
|
+
if (cleanedIns) {
|
|
317
|
+
result.push({ value: cleanedIns, added: true, count: 1 });
|
|
318
|
+
}
|
|
319
|
+
if (wsSuffix) {
|
|
320
|
+
result.push({ value: wsSuffix, count: 1 });
|
|
321
|
+
}
|
|
322
|
+
i++; // Skip the insertion since we processed it
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// Scenario 2: Lone insertion after unchanged text
|
|
326
|
+
if (change.added && i > 0 && !changes[i - 1].added && !changes[i - 1].removed) {
|
|
327
|
+
const prev = result[result.length - 1];
|
|
328
|
+
if (prev && !prev.added && !prev.removed) {
|
|
329
|
+
// Check for duplicate leading whitespace
|
|
330
|
+
const leadingWs = change.value.match(/^\s*/)?.[0] || "";
|
|
331
|
+
const trailingWs = prev.value.match(/\s*$/)?.[0] || "";
|
|
332
|
+
if (leadingWs && trailingWs) {
|
|
333
|
+
const overlap = longestCommonSuffix(trailingWs, leadingWs);
|
|
334
|
+
if (overlap) {
|
|
335
|
+
// Remove overlap from the insertion
|
|
336
|
+
result.push({
|
|
337
|
+
value: change.value.slice(overlap.length),
|
|
338
|
+
added: true,
|
|
339
|
+
count: 1,
|
|
340
|
+
});
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// Scenario 3: Lone deletion between unchanged text
|
|
347
|
+
if (change.removed &&
|
|
348
|
+
!changes[i + 1]?.added &&
|
|
349
|
+
i > 0 &&
|
|
350
|
+
!changes[i - 1]?.added &&
|
|
351
|
+
!changes[i - 1]?.removed) {
|
|
352
|
+
const prev = result[result.length - 1];
|
|
353
|
+
const next = changes[i + 1];
|
|
354
|
+
if (prev && next && !next.added && !next.removed) {
|
|
355
|
+
const leadingWs = change.value.match(/^\s*/)?.[0] || "";
|
|
356
|
+
const trailingWs = change.value.match(/\s*$/)?.[0] || "";
|
|
357
|
+
const prevTrailingWs = prev.value.match(/\s*$/)?.[0] || "";
|
|
358
|
+
const nextLeadingWs = next.value.match(/^\s*/)?.[0] || "";
|
|
359
|
+
// If deletion starts/ends with whitespace that overlaps with neighbors
|
|
360
|
+
if (leadingWs && prevTrailingWs) {
|
|
361
|
+
const overlap = longestCommonSuffix(prevTrailingWs, leadingWs);
|
|
362
|
+
if (overlap.length === leadingWs.length) {
|
|
363
|
+
// Leading whitespace is already in prev, strip it
|
|
364
|
+
result.push({
|
|
365
|
+
value: change.value.slice(leadingWs.length),
|
|
366
|
+
removed: true,
|
|
367
|
+
count: 1,
|
|
368
|
+
});
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (trailingWs && nextLeadingWs) {
|
|
373
|
+
const overlap = longestCommonPrefix(trailingWs, nextLeadingWs);
|
|
374
|
+
if (overlap.length === trailingWs.length) {
|
|
375
|
+
// Trailing whitespace will be in next, strip it
|
|
376
|
+
result.push({
|
|
377
|
+
value: change.value.slice(0, -trailingWs.length) || change.value,
|
|
378
|
+
removed: true,
|
|
379
|
+
count: 1,
|
|
380
|
+
});
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// Default: just add the change as-is
|
|
387
|
+
result.push({ ...change });
|
|
388
|
+
}
|
|
389
|
+
return mergeConsecutiveChanges(result);
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Compare two strings line by line.
|
|
393
|
+
* Similar to Diff.diffLines from the 'diff' package.
|
|
394
|
+
*/
|
|
395
|
+
export function diffLines(oldStr, newStr) {
|
|
396
|
+
const oldLines = splitLines(oldStr);
|
|
397
|
+
const newLines = splitLines(newStr);
|
|
398
|
+
return computeDiff(oldLines, newLines);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Compare two strings word by word, preserving whitespace.
|
|
402
|
+
* Similar to Diff.diffWordsWithSpace from the 'diff' package.
|
|
403
|
+
*
|
|
404
|
+
* Features matching jsdiff:
|
|
405
|
+
* - Extended Unicode word character support
|
|
406
|
+
* - Proper tokenization (words, whitespace runs, single punctuation)
|
|
407
|
+
* - Whitespace deduplication in consecutive changes
|
|
408
|
+
*/
|
|
409
|
+
export function diffWordsWithSpace(oldStr, newStr) {
|
|
410
|
+
const oldWords = tokenizeWords(oldStr);
|
|
411
|
+
const newWords = tokenizeWords(newStr);
|
|
412
|
+
const diff = computeDiff(oldWords, newWords);
|
|
413
|
+
return dedupeWhitespaceInChangeObjects(diff);
|
|
414
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepcitation/deepcitation-js",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.25",
|
|
4
4
|
"description": "DeepCitation JavaScript SDK for deterministic AI citation verification",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -34,8 +34,7 @@
|
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"react": ">=17.0.0",
|
|
36
36
|
"react-dom": ">=17.0.0",
|
|
37
|
-
"@radix-ui/react-popover": "^1.0.0"
|
|
38
|
-
"diff": "^8.0.0"
|
|
37
|
+
"@radix-ui/react-popover": "^1.0.0"
|
|
39
38
|
},
|
|
40
39
|
"peerDependenciesMeta": {
|
|
41
40
|
"react": {
|
|
@@ -46,13 +45,9 @@
|
|
|
46
45
|
},
|
|
47
46
|
"@radix-ui/react-popover": {
|
|
48
47
|
"optional": true
|
|
49
|
-
},
|
|
50
|
-
"diff": {
|
|
51
|
-
"optional": true
|
|
52
48
|
}
|
|
53
49
|
},
|
|
54
50
|
"devDependencies": {
|
|
55
|
-
"diff": "^8.0.2",
|
|
56
51
|
"@happy-dom/global-registrator": "^20.0.11",
|
|
57
52
|
"@playwright/experimental-ct-react": "^1.57.0",
|
|
58
53
|
"@playwright/test": "^1.57.0",
|