@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.
@@ -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 { isVerified: false, isMiss: false, isPartialMatch: false, isPending: true };
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(Spinner, {}) }));
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 && !status.isPartialMatch && "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 }))] }));
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 ? expectedText.slice(0, 100) + "…" : 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" })] })] }));
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: [_jsx("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 ? expectedText.slice(0, 100) + "…" : expectedText })] }), _jsxs("div", { children: [_jsx("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 ? actualText.slice(0, 100) + "…" : actualText })] })] })) : (
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 ? expectedText.slice(0, 100) + "…" : 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(", ")] })] }))] }));
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 && verification?.verificationImageBase64) {
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
- }, [eventHandlers, behaviorConfig, citation, citationKey, getBehaviorContext]);
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
- }, [eventHandlers, behaviorConfig, citation, citationKey, getBehaviorContext]);
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 && fallbackDisplay !== undefined && !hideKeySpan && isMiss) {
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) && variant === "brackets" && "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");
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 || verification.verifiedMatchSnippet);
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;
@@ -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;
@@ -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 * as Diff from "diff";
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 = Diff.diffLines(cleanExpected, cleanActual);
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 = Diff.diffWordsWithSpace(part.value, nextPart.value);
24
+ const wordDiffs = diffWordsWithSpace(part.value, nextPart.value);
25
25
  processedDiffs.push({
26
26
  type: "modified",
27
27
  parts: wordDiffs,
@@ -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
@@ -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.23",
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",