UNPKG

@crashbytes/react-version-compare

Version:

A React component for comparing strings and arrays with precise word-level and item-level highlighting of differences.

352 lines (348 loc) 11.4 kB
import { jsx, jsxs } from 'react/jsx-runtime'; import { useState, useEffect } from 'react'; import { BLOCKS } from '@contentful/rich-text-types'; import * as Diff from 'diff'; // Exported type guard for Contentful documents function isContentfulDocument(value) { return value && typeof value === 'object' && value.nodeType === BLOCKS.DOCUMENT && Array.isArray(value.content); } // Extract plain text from Contentful document function extractPlainText(document) { const extractFromNode = node => { if (node.nodeType === 'text') { return node.value; } if ('content' in node && node.content) { return node.content.map(child => extractFromNode(child)).join(''); } return ''; }; return extractFromNode(document); } // Extract structured content for structure diff function extractStructuredContent(document) { const result = []; const extractFromNode = node => { if (node.nodeType === 'text') return; if ('content' in node && node.content) { const textContent = node.content.map(child => child.nodeType === 'text' ? child.value : '').join(''); let displayType = node.nodeType; let headingLevel; switch (node.nodeType) { case BLOCKS.HEADING_1: displayType = 'Heading'; headingLevel = 1; break; case BLOCKS.HEADING_2: displayType = 'Heading'; headingLevel = 2; break; case BLOCKS.HEADING_3: displayType = 'Heading'; headingLevel = 3; break; case BLOCKS.HEADING_4: displayType = 'Heading'; headingLevel = 4; break; case BLOCKS.HEADING_5: displayType = 'Heading'; headingLevel = 5; break; case BLOCKS.HEADING_6: displayType = 'Heading'; headingLevel = 6; break; case BLOCKS.PARAGRAPH: displayType = 'Text'; break; case BLOCKS.UL_LIST: displayType = 'List'; break; case BLOCKS.OL_LIST: displayType = 'Numbered List'; break; case BLOCKS.LIST_ITEM: displayType = 'List Item'; break; case BLOCKS.QUOTE: displayType = 'Quote'; break; case BLOCKS.TABLE: displayType = 'Table'; break; default: displayType = node.nodeType.charAt(0).toUpperCase() + node.nodeType.slice(1); } if (textContent.trim()) { result.push({ type: displayType, content: textContent.trim(), level: headingLevel }); } node.content.forEach(child => { if (child.nodeType !== 'text') extractFromNode(child); }); } }; if (document.content) document.content.forEach(node => extractFromNode(node)); return result; } // Main Contentful diff renderer async function renderContentfulDiff(origDoc, modDoc, compareMode, caseSensitive, renderStringDiff) { // Dynamically import diff for Vite/ESM compatibility const DiffModule = await import('diff'); const Diff = DiffModule.default ?? DiffModule; const { diffWords, diffArrays } = Diff; if (compareMode === 'structure') { const origStructure = extractStructuredContent(origDoc); const modStructure = extractStructuredContent(modDoc); const diff = diffArrays(origStructure, modStructure, { comparator: (a, b) => a.type === b.type && a.content === b.content && a.level === b.level }); const originalParts = []; const modifiedParts = []; let origIdx = 0; let modIdx = 0; diff.forEach(part => { if (part.added) { part.value.forEach((modItem, i) => { originalParts.push(jsx("div", { className: "diff-blank-line" }, `blank-orig-${modIdx + i}`)); modifiedParts.push(jsxs("div", { className: "diff-added-line", children: [jsx("span", { className: "diff-structure-type", children: modItem.type }), modItem.level && jsxs("span", { className: "diff-structure-level", children: [" H", modItem.level] }), jsxs("span", { className: "diff-structure-content", children: [": ", modItem.content] })] }, `added-mod-${modIdx + i}`)); }); modIdx += part.count || 0; } else if (part.removed) { part.value.forEach((origItem, i) => { originalParts.push(jsxs("div", { className: "diff-removed-line", children: [jsx("span", { className: "diff-structure-type", children: origItem.type }), origItem.level && jsxs("span", { className: "diff-structure-level", children: [" H", origItem.level] }), jsxs("span", { className: "diff-structure-content", children: [": ", origItem.content] })] }, `removed-orig-${origIdx + i}`)); modifiedParts.push(jsx("div", { className: "diff-blank-line" }, `blank-mod-${origIdx + i}`)); }); origIdx += part.count || 0; } else { part.value.forEach((item, i) => { originalParts.push(jsxs("div", { className: "diff-unchanged-line", children: [jsx("span", { className: "diff-structure-type", children: item.type }), item.level && jsxs("span", { className: "diff-structure-level", children: [" H", item.level] }), jsxs("span", { className: "diff-structure-content", children: [": ", item.content] })] }, `unchanged-orig-${origIdx + i}`)); modifiedParts.push(jsxs("div", { className: "diff-unchanged-line", children: [jsx("span", { className: "diff-structure-type", children: item.type }), item.level && jsxs("span", { className: "diff-structure-level", children: [" H", item.level] }), jsxs("span", { className: "diff-structure-content", children: [": ", item.content] })] }, `unchanged-mod-${modIdx + i}`)); }); origIdx += part.count || 0; modIdx += part.count || 0; } }); return { originalParts, modifiedParts }; } else { // Text-based comparison of Contentful documents const origText = extractPlainText(origDoc); const modText = extractPlainText(modDoc); return renderStringDiff(origText, modText); } } function renderStringDiff(orig, mod) { const difference = Diff.diffWords(orig, mod); const originalParts = []; const modifiedParts = []; for (const part of difference) { if (part.removed) { originalParts.push(jsx("span", { className: "diff-removed", children: part.value }, originalParts.length)); } else if (part.added) { modifiedParts.push(jsx("span", { className: "diff-added", children: part.value }, modifiedParts.length)); } else { originalParts.push(jsx("span", { className: "diff-unchanged", children: part.value }, originalParts.length)); modifiedParts.push(jsx("span", { className: "diff-unchanged", children: part.value }, modifiedParts.length)); } } return { originalParts, modifiedParts }; } function renderArrayDiff(original, modified) { const maxLength = Math.max(original.length, modified.length); const originalParts = []; const modifiedParts = []; for (let i = 0; i < maxLength; i++) { const orig = original[i] ?? ''; const mod = modified[i] ?? ''; if (orig === mod) { originalParts.push(jsx("div", { className: "diff-unchanged-line", children: orig }, `orig-${i}`)); modifiedParts.push(jsx("div", { className: "diff-unchanged-line", children: mod }, `mod-${i}`)); } else { originalParts.push(jsx("div", { className: orig ? "diff-removed-line" : "diff-blank-line", children: orig }, `orig-${i}`)); modifiedParts.push(jsx("div", { className: mod ? "diff-added-line" : "diff-blank-line", children: mod }, `mod-${i}`)); } } return { originalParts, modifiedParts }; } const Compare = ({ original, modified, className = '', viewMode = 'side-by-side', caseSensitive = true, compareMode = 'text' }) => { const isStringComparison = typeof original === 'string' && typeof modified === 'string'; const isArrayComparison = Array.isArray(original) && Array.isArray(modified); const isContentfulComparison = isContentfulDocument(original) && isContentfulDocument(modified); const [contentfulParts, setContentfulParts] = useState(null); useEffect(() => { let cancelled = false; if (isContentfulComparison) { setContentfulParts(null); // reset while loading renderContentfulDiff(original, modified, compareMode, caseSensitive, renderStringDiff).then(result => { if (!cancelled) setContentfulParts(result); }); } return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [original, modified, compareMode, caseSensitive, isContentfulComparison]); let originalParts = [], modifiedParts = []; if (isStringComparison) { ({ originalParts, modifiedParts } = renderStringDiff(original, modified)); } else if (isArrayComparison) { ({ originalParts, modifiedParts } = renderArrayDiff(original, modified)); } else if (isContentfulComparison) { if (contentfulParts) { originalParts = contentfulParts.originalParts; modifiedParts = contentfulParts.modifiedParts; } else { originalParts = [jsx("div", { children: "Loading..." }, "loading")]; modifiedParts = [jsx("div", { children: "Loading..." }, "loading")]; } } if (!isStringComparison && !isArrayComparison && !isContentfulComparison) { return jsx("div", { className: `compare-error ${className}`, children: "Error: Invalid input for comparison." }); } if (viewMode === 'inline') { return jsx("div", { className: `compare-inline ${className}`, children: jsxs("div", { className: "compare-content", children: [originalParts, modifiedParts] }) }); } return jsxs("div", { className: `compare-side-by-side ${className}`, children: [jsxs("div", { className: "compare-panel", children: [jsx("div", { className: "compare-header original-header", children: "Original" }), jsx("div", { className: "compare-content original-content", children: originalParts })] }), jsxs("div", { className: "compare-panel", children: [jsx("div", { className: "compare-header modified-header", children: "Modified" }), jsx("div", { className: "compare-content modified-content", children: modifiedParts })] })] }); }; export { Compare, Compare as default }; //# sourceMappingURL=index.js.map