diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index be4fce55d9bca..c4e74a4371136 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4243,9 +4243,6 @@ importers:
clsx:
specifier: 2.1.1
version: 2.1.1
- diff:
- specifier: ^4.0.2
- version: 4.0.2
moment:
specifier: 2.29.4
version: 2.29.4
diff --git a/projects/js-packages/components/changelog/add-diff-viewer-component b/projects/js-packages/components/changelog/add-diff-viewer-component
new file mode 100644
index 0000000000000..2a305290fe23c
--- /dev/null
+++ b/projects/js-packages/components/changelog/add-diff-viewer-component
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Add DiffViewer component
diff --git a/projects/plugins/protect/src/js/components/diff-viewer/README.md b/projects/js-packages/components/components/diff-viewer/README.md
similarity index 75%
rename from projects/plugins/protect/src/js/components/diff-viewer/README.md
rename to projects/js-packages/components/components/diff-viewer/README.md
index 0753deb9a3a53..8cf4a6783431f 100644
--- a/projects/plugins/protect/src/js/components/diff-viewer/README.md
+++ b/projects/js-packages/components/components/diff-viewer/README.md
@@ -1,6 +1,6 @@
# Unified Diff Viewer
-Forked over from [Calypso](https://github.com/Automattic/wp-calypso/tree/b7a4a07/client/components/diff-viewer).
+Originally forked over from [Calypso](https://github.com/Automattic/wp-calypso/tree/b7a4a07/client/components/diff-viewer).
This component renders the output of a unified diff (`git diff` or `diff -u`) in a
visual format recognizable by someone who works with `diff` and comparing files.
@@ -29,9 +29,9 @@ export const CommitView = ( { commitHash, description, diff } ) => (
### Additional usage information
The diff output should be the full text produced by the diff command (including newlines).
-Internally this component relies on `jsdiff` to parse the output (the patch) and produce
-the data structure used to display files, hunks (sections of change in the files), and
-the actual lines of change and context.
+Internally this component parses the output (the patch) and produces the data structure
+used to display files, hunks (sections of change in the files), and the actual lines of
+change and context.
```
diff --git a/circle.yml b/circle.yml
diff --git a/projects/js-packages/components/components/diff-viewer/index.tsx b/projects/js-packages/components/components/diff-viewer/index.tsx
new file mode 100644
index 0000000000000..8e9bd3e76e47c
--- /dev/null
+++ b/projects/js-packages/components/components/diff-viewer/index.tsx
@@ -0,0 +1,97 @@
+import { Fragment } from 'react';
+import parseFilename from './parse-filename';
+import parsePatch from './parse-patch';
+import styles from './styles.module.scss';
+
+const filename = ( {
+ oldFileName,
+ newFileName,
+}: {
+ oldFileName: string;
+ newFileName: string;
+} ): JSX.Element => {
+ const { prev, next } = parseFilename( oldFileName, newFileName );
+
+ if ( prev.prefix + prev.path === next.prefix + next.path ) {
+ return (
+
+ { prev.prefix && (
+ { prev.prefix }
+ ) }
+ { prev.path }
+
+ );
+ }
+
+ return (
+
+ { !! prev.prefix && (
+ { prev.prefix }
+ ) }
+ { prev.path }
+ { ' → ' }
+ { !! next.prefix && (
+ { next.prefix }
+ ) }
+ { next.path }
+
+ );
+};
+
+export const DiffViewer = ( { diff } ) => (
+
+ { parsePatch( diff ).map( ( file, fileIndex ) => (
+
+
+ { filename( file ) }
+
+
+
+ { file.hunks.map( ( hunk, hunkIndex ) => {
+ let lineOffset = 0;
+ return hunk.lines.map( ( line, index ) => (
+
+ { line[ 0 ] === '+' ? '\u00a0' : hunk.oldStart + lineOffset++ }
+
+ ) );
+ } ) }
+
+
+ { file.hunks.map( ( hunk, hunkIndex ) => {
+ let lineOffset = 0;
+ return hunk.lines.map( ( line, index ) => (
+
+ { line[ 0 ] === '-' ? '\u00a0' : hunk.newStart + lineOffset++ }
+
+ ) );
+ } ) }
+
+
+ { file.hunks.map( ( hunk, hunkIndex ) =>
+ hunk.lines.map( ( line, index ) => {
+ const output = line.slice( 1 ).replace( /^\s*$/, '\u00a0' );
+ const key = `${ hunkIndex }-${ index }`;
+
+ switch ( line[ 0 ] ) {
+ case ' ':
+ return
{ output }
;
+
+ case '-':
+ return
{ output };
+
+ case '+':
+ return
{ output };
+
+ default:
+ return undefined;
+ }
+ } )
+ ) }
+
+
+
+ ) ) }
+
+);
+
+export default DiffViewer;
diff --git a/projects/js-packages/components/components/diff-viewer/parse-filename.ts b/projects/js-packages/components/components/diff-viewer/parse-filename.ts
new file mode 100644
index 0000000000000..5e51f8569c06a
--- /dev/null
+++ b/projects/js-packages/components/components/diff-viewer/parse-filename.ts
@@ -0,0 +1,75 @@
+type ParsedFilename = {
+ prefix: string;
+ path: string;
+};
+
+const decompose = ( path: string ): ParsedFilename => {
+ const lastSlash = path.lastIndexOf( '/' );
+
+ return lastSlash > -1
+ ? { prefix: path.slice( 0, lastSlash ), path: path.slice( lastSlash ) }
+ : { prefix: '', path };
+};
+
+/**
+ * Parse the filename from a diff
+ *
+ * Uses a heuristic to return proper file name indicators
+ *
+ * It searches for the longest shared prefix and returns
+ * whatever remains after that. If the paths are identical
+ * it only returns a single filename as we have detected
+ * that the diff compares changes to only one file.
+ *
+ * An exception is made for `a/` and `b/` prefixes often
+ * added by `git` and other utilities to separate the left
+ * from the right when looking at the contents of a single
+ * file over time.
+ *
+ * @param {string} prev - filename of left contents
+ * @param {string} next - filename of right contents
+ *
+ * @return {object} - parsed filename
+ */
+export default function (
+ prev: string,
+ next: string
+): { prev: ParsedFilename; next: ParsedFilename } {
+ // Remove 'a/' and 'b/' prefixes if present
+ const isLikelyPrefixed = prev.startsWith( 'a/' ) && next.startsWith( 'b/' );
+ prev = isLikelyPrefixed ? prev.slice( 2 ) : prev;
+ next = isLikelyPrefixed ? next.slice( 2 ) : next;
+
+ if ( prev === next ) {
+ // Paths are identical
+ const { prefix, path } = decompose( prev );
+ return { prev: { prefix, path }, next: { prefix, path } };
+ }
+
+ // Find longest shared base path ending with a slash
+ const length = Math.max( prev.length, next.length );
+ for ( let i = 0, slash = 0; i < length; i++ ) {
+ if ( prev[ i ] === '/' && next[ i ] === '/' ) {
+ slash = i;
+ }
+
+ if ( prev[ i ] !== next[ i ] ) {
+ return {
+ prev: {
+ prefix: prev.slice( 0, slash ),
+ path: prev.slice( slash ),
+ },
+ next: {
+ prefix: next.slice( 0, slash ),
+ path: next.slice( slash ),
+ },
+ };
+ }
+ }
+
+ // No shared base path
+ return {
+ prev: decompose( prev ),
+ next: decompose( next ),
+ };
+}
diff --git a/projects/js-packages/components/components/diff-viewer/parse-patch.ts b/projects/js-packages/components/components/diff-viewer/parse-patch.ts
new file mode 100644
index 0000000000000..69281bbdc63a3
--- /dev/null
+++ b/projects/js-packages/components/components/diff-viewer/parse-patch.ts
@@ -0,0 +1,212 @@
+type Hunk = {
+ oldStart: number;
+ oldLines: number;
+ newStart: number;
+ newLines: number;
+ lines: string[];
+};
+
+type Index = {
+ index?: string;
+ hunks?: Hunk[];
+ oldFileName?: string;
+ newFileName?: string;
+ oldHeader?: string;
+ newHeader?: string;
+};
+
+/**
+ * Parse Patch
+ *
+ * Adapted from https://github.com/kpdecker/jsdiff/blob/master/src/patch/parse.js
+ *
+ * @param {string} uniDiff - diff string
+ * @return {Array} - array of parsed files
+ */
+export default function parsePatch( uniDiff: string ) {
+ const diffstr = uniDiff.split( /\n/ );
+ const list = [];
+ let i = 0;
+
+ /**
+ * Parse Index
+ */
+ function parseIndex() {
+ const index: Index = {};
+
+ list.push( index );
+
+ // Parse diff metadata
+ while ( i < diffstr.length ) {
+ const line = diffstr[ i ];
+
+ // File header found, end parsing diff metadata
+ if ( /^(---|\+\+\+|@@)\s/.test( line ) ) {
+ break;
+ }
+
+ // Diff index
+ const header = /^(?:Index:|diff(?: -r \w+)+)\s+(.+?)\s*$/.exec( line );
+
+ if ( header ) {
+ index.index = header[ 1 ];
+ }
+
+ i++;
+ }
+
+ // Parse file headers if they are defined. Unified diff requires them, but
+ // there's no technical issues to have an isolated hunk without file header
+ parseFileHeader( index );
+ parseFileHeader( index );
+
+ // Parse hunks
+ index.hunks = [];
+
+ while ( i < diffstr.length ) {
+ const _line = diffstr[ i ];
+
+ if (
+ /^(Index:\s|diff\s|---\s|\+\+\+\s|===================================================================)/.test(
+ _line
+ )
+ ) {
+ break;
+ } else if ( /^@@/.test( _line ) ) {
+ index.hunks.push( parseHunk() );
+ } else if ( _line ) {
+ throw new Error( 'Unknown line ' + ( i + 1 ) + ' ' + JSON.stringify( _line ) );
+ } else {
+ i++;
+ }
+ }
+ }
+
+ /**
+ * Parse File Header
+ *
+ * Parses the --- and +++ headers, if none are found, no lines
+ * are consumed.
+ *
+ * @param {Array} index - array of parsed files
+ * @param {unknown} index.index - index
+ * @param {object[]} index.hunks - hunks
+ */
+ function parseFileHeader( index: Index ) {
+ const fileHeader = /^(---|\+\+\+)\s+(.*)\r?$/.exec( diffstr[ i ] );
+
+ if ( fileHeader ) {
+ const keyPrefix = fileHeader[ 1 ] === '---' ? 'old' : 'new';
+
+ const data = fileHeader[ 2 ].split( '\t', 2 );
+
+ let fileName = data[ 0 ].replace( /\\\\/g, '\\' );
+
+ if ( /^".*"$/.test( fileName ) ) {
+ fileName = fileName.substr( 1, fileName.length - 2 );
+ }
+
+ index[ keyPrefix + 'FileName' ] = fileName;
+
+ index[ keyPrefix + 'Header' ] = ( data[ 1 ] || '' ).trim();
+
+ i++;
+ }
+ }
+
+ /**
+ * Parse Hunk
+ * This assumes that we are at the start of a hunk.
+ *
+ * @return {object} - The parsed hunk.
+ */
+ function parseHunk(): Hunk {
+ const chunkHeaderIndex = i,
+ chunkHeaderLine = diffstr[ i++ ],
+ chunkHeader = chunkHeaderLine.split( /@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/ );
+
+ const hunk = {
+ oldStart: +chunkHeader[ 1 ],
+ oldLines: typeof chunkHeader[ 2 ] === 'undefined' ? 1 : +chunkHeader[ 2 ],
+ newStart: +chunkHeader[ 3 ],
+ newLines: typeof chunkHeader[ 4 ] === 'undefined' ? 1 : +chunkHeader[ 4 ],
+ lines: [],
+ };
+
+ // Unified Diff Format quirk: If the chunk size is 0,
+ // the first number is one lower than one would expect.
+ // https://www.artima.com/weblogs/viewpost.jsp?thread=164293
+
+ if ( hunk.oldLines === 0 ) {
+ hunk.oldStart += 1;
+ }
+
+ if ( hunk.newLines === 0 ) {
+ hunk.newStart += 1;
+ }
+
+ let addCount = 0,
+ removeCount = 0,
+ _diffstr$i;
+ for (
+ ;
+ i < diffstr.length &&
+ ( removeCount < hunk.oldLines ||
+ addCount < hunk.newLines ||
+ ( ( _diffstr$i = diffstr[ i ] ) !== null &&
+ _diffstr$i !== void 0 &&
+ _diffstr$i.startsWith( '\\' ) ) );
+ i++
+ ) {
+ const operation =
+ diffstr[ i ].length === 0 && i !== diffstr.length - 1 ? ' ' : diffstr[ i ][ 0 ];
+
+ if ( operation === '+' || operation === '-' || operation === ' ' || operation === '\\' ) {
+ hunk.lines.push( diffstr[ i ] );
+
+ if ( operation === '+' ) {
+ addCount++;
+ } else if ( operation === '-' ) {
+ removeCount++;
+ } else if ( operation === ' ' ) {
+ addCount++;
+ removeCount++;
+ }
+ } else {
+ throw new Error(
+ `Hunk at line ${ chunkHeaderIndex + 1 } contained invalid line ${ diffstr[ i ] }`
+ );
+ }
+ }
+
+ // Handle the empty block count case
+ if ( ! addCount && hunk.newLines === 1 ) {
+ hunk.newLines = 0;
+ }
+
+ if ( ! removeCount && hunk.oldLines === 1 ) {
+ hunk.oldLines = 0;
+ }
+
+ // Perform sanity checking
+ if ( addCount !== hunk.newLines ) {
+ throw new Error(
+ 'Added line count did not match for hunk at line ' + ( chunkHeaderIndex + 1 )
+ );
+ }
+
+ if ( removeCount !== hunk.oldLines ) {
+ throw new Error(
+ 'Removed line count did not match for hunk at line ' + ( chunkHeaderIndex + 1 )
+ );
+ }
+
+ return hunk;
+ }
+
+ while ( i < diffstr.length ) {
+ parseIndex();
+ }
+
+ return list;
+}
diff --git a/projects/js-packages/components/components/diff-viewer/stories/index.stories.tsx b/projects/js-packages/components/components/diff-viewer/stories/index.stories.tsx
new file mode 100644
index 0000000000000..4f986981f9761
--- /dev/null
+++ b/projects/js-packages/components/components/diff-viewer/stories/index.stories.tsx
@@ -0,0 +1,39 @@
+/* eslint-disable react/react-in-jsx-scope */
+import React from 'react';
+import DiffViewer from '..';
+
+export default {
+ title: 'JS Packages/Components/Diff Viewer',
+ component: DiffViewer,
+};
+
+const diff = `diff --git a/package.json b/package.json
+Index: a31e51f..c3b21a1 100644
+--- a/package.json
++++ b/package.json
+@@ -1,7 +1,7 @@
+ {
+ "name": "hello-world",
+- "version": "1.0.0",
++ "version": "1.0.1",
+ "description": "Hello, World!",
+- "main": "index.js",
++ "main": "index.ts",
+ "scripts": {
+- "start": "node index.js"
++ "start": "node index.ts"
+
+diff --git a/src/index.js b/src/index.ts
+Index: 17c882a..d3f041b 100644
+--- a/src/index.js
++++ b/src/index.ts
+@@ -0,0 +1,1 @@
++console.log( 'Hello, world!' );`;
+
+const Template = args => ;
+
+export const Default = Template.bind( {} );
+
+Default.args = {
+ diff,
+};
diff --git a/projects/plugins/protect/src/js/components/diff-viewer/styles.module.scss b/projects/js-packages/components/components/diff-viewer/styles.module.scss
similarity index 100%
rename from projects/plugins/protect/src/js/components/diff-viewer/styles.module.scss
rename to projects/js-packages/components/components/diff-viewer/styles.module.scss
diff --git a/projects/js-packages/components/index.ts b/projects/js-packages/components/index.ts
index b4740fcff7404..ea8814edf8dbd 100644
--- a/projects/js-packages/components/index.ts
+++ b/projects/js-packages/components/index.ts
@@ -78,4 +78,5 @@ export { default as UpsellBanner } from './components/upsell-banner';
export { getUserLocale, cleanLocale } from './lib/locale';
export { default as RadioControl } from './components/radio-control';
export { default as StatCard } from './components/stat-card';
+export { default as DiffViewer } from './components/diff-viewer';
export * from './components/global-notices';
diff --git a/projects/plugins/protect/changelog/add-diff-viewer-component b/projects/plugins/protect/changelog/add-diff-viewer-component
new file mode 100644
index 0000000000000..2a305290fe23c
--- /dev/null
+++ b/projects/plugins/protect/changelog/add-diff-viewer-component
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Add DiffViewer component
diff --git a/projects/plugins/protect/package.json b/projects/plugins/protect/package.json
index 1a95d9b80cede..fc36ae5e2ce05 100644
--- a/projects/plugins/protect/package.json
+++ b/projects/plugins/protect/package.json
@@ -41,7 +41,6 @@
"@wordpress/url": "4.8.1",
"camelize": "1.0.1",
"clsx": "2.1.1",
- "diff": "^4.0.2",
"moment": "2.29.4",
"prop-types": "15.8.1",
"react": "18.3.1",
diff --git a/projects/plugins/protect/src/js/components/diff-viewer/index.jsx b/projects/plugins/protect/src/js/components/diff-viewer/index.jsx
deleted file mode 100644
index 2f26a4ae9c19b..0000000000000
--- a/projects/plugins/protect/src/js/components/diff-viewer/index.jsx
+++ /dev/null
@@ -1,156 +0,0 @@
-import { parsePatch } from 'diff/lib/patch/parse';
-import { Fragment } from 'react';
-import styles from './styles.module.scss';
-
-const decompose = path => {
- const lastSlash = path.lastIndexOf( '/' );
-
- return lastSlash > -1 ? [ path.slice( 0, lastSlash ), path.slice( lastSlash ) ] : [ '', path ];
-};
-
-/**
- * Uses a heuristic to return proper file name indicators
- *
- * This function lists the filenames for the left and right
- * side of the diff in a single string.
- *
- * It searches for the longest shared prefix and returns
- * whatever remains after that. If the paths are identical
- * it only returns a single filename as we have detected
- * that the diff compares changes to only one file.
- *
- * An exception is made for `a/` and `b/` prefixes often
- * added by `git` and other utilities to separate the left
- * from the right when looking at the contents of a single
- * file over time.
- *
- * @param {object} options - deconstructed argument
- * @param {string} options.oldFileName - filename of left contents
- * @param {string} options.newFileName - filename of right contents
- * @return {window.Element} description of the file or files in the diff
- */
-const filename = ( { oldFileName, newFileName } ) => {
- // if we think the diff utility added a bogus
- // prefix then cut it off
- const isLikelyPrefixed =
- 'a' === oldFileName[ 0 ] &&
- '/' === oldFileName[ 1 ] &&
- 'b' === newFileName[ 0 ] &&
- '/' === newFileName[ 1 ];
-
- const [ prev, next ] = isLikelyPrefixed
- ? [ oldFileName.slice( 2 ), newFileName.slice( 2 ) ]
- : [ oldFileName, newFileName ];
-
- if ( prev === next ) {
- const [ base, name ] = decompose( prev );
-
- // it's the same file, return the single name
- return (
-
- { base && { base } }
- { name }
-
- );
- }
-
- // find the longest shared path prefix
- const length = Math.max( prev.length, next.length );
- for ( let i = 0, slash = 0; i < length; i++ ) {
- if ( prev[ i ] === '/' && next[ i ] === '/' ) {
- slash = i;
- }
-
- if ( prev[ i ] !== next[ i ] ) {
- return (
-
- { slash !== 0 && (
-
- { prev.slice( 0, slash ) }
-
- ) }
- { prev.slice( slash ) }
- { ' → ' }
- { slash !== 0 && (
-
- { next.slice( 0, slash ) }
-
- ) }
- { next.slice( slash ) }
-
- );
- }
- }
-
- // otherwise we have no shared prefix
- const [ prevBase, prevName ] = decompose( prev );
- const [ nextBase, nextName ] = decompose( next );
-
- return (
-
- { prevBase && { prevBase } }
- { prevName }
- { ' → ' }
- { nextBase && { nextBase } }
- { nextName }
-
- );
-};
-
-export const DiffViewer = ( { diff } ) => (
-
- { parsePatch( diff ).map( ( file, fileIndex ) => (
-
-
- { filename( file ) }
-
-
-
- { file.hunks.map( ( hunk, hunkIndex ) => {
- let lineOffset = 0;
- return hunk.lines.map( ( line, index ) => (
-
- { line[ 0 ] === '+' ? '\u00a0' : hunk.oldStart + lineOffset++ }
-
- ) );
- } ) }
-
-
- { file.hunks.map( ( hunk, hunkIndex ) => {
- let lineOffset = 0;
- return hunk.lines.map( ( line, index ) => (
-
- { line[ 0 ] === '-' ? '\u00a0' : hunk.newStart + lineOffset++ }
-
- ) );
- } ) }
-
-
- { file.hunks.map( ( hunk, hunkIndex ) =>
- hunk.lines.map( ( line, index ) => {
- const output = line.slice( 1 ).replace( /^\s*$/, '\u00a0' );
- const key = `${ hunkIndex }-${ index }`;
-
- switch ( line[ 0 ] ) {
- case ' ':
- return
{ output }
;
-
- case '-':
- return
{ output };
-
- case '+':
- return
{ output };
-
- default:
- return undefined;
- }
- } )
- ) }
-
-
-
- ) ) }
-
-);
-
-export default DiffViewer;
diff --git a/projects/plugins/protect/src/js/components/diff-viewer/stories/index.stories.jsx b/projects/plugins/protect/src/js/components/diff-viewer/stories/index.stories.jsx
deleted file mode 100644
index 22968cb1e33f8..0000000000000
--- a/projects/plugins/protect/src/js/components/diff-viewer/stories/index.stories.jsx
+++ /dev/null
@@ -1,25 +0,0 @@
-/* eslint-disable react/react-in-jsx-scope */
-import React from 'react';
-import DiffViewer from '../index.jsx';
-
-export default {
- title: 'Plugins/Protect/Diff Viewer',
- component: DiffViewer,
-};
-
-const diff = `index 51455bdb14..bc0622d001 100644
---- a/circle.yml
-+++ b/circle.yml
-@@ -1,6 +1,6 @@
- machine:
- node:
-- version: 8.9.4
-+ version: 8.11.0
- test:
- pre:
- - ? |`;
-
-export const Default = args => ;
-Default.args = {
- diff,
-};
diff --git a/projects/plugins/protect/src/js/components/threats-list/paid-list.jsx b/projects/plugins/protect/src/js/components/threats-list/paid-list.jsx
index e40178266cd08..d17e16a179e16 100644
--- a/projects/plugins/protect/src/js/components/threats-list/paid-list.jsx
+++ b/projects/plugins/protect/src/js/components/threats-list/paid-list.jsx
@@ -1,10 +1,9 @@
-import { Text, Button, useBreakpointMatch } from '@automattic/jetpack-components';
+import { Text, Button, useBreakpointMatch, DiffViewer } from '@automattic/jetpack-components';
import { __, sprintf } from '@wordpress/i18n';
import React, { useCallback } from 'react';
import useAnalyticsTracks from '../../hooks/use-analytics-tracks';
import useFixers from '../../hooks/use-fixers';
import useModal from '../../hooks/use-modal';
-import DiffViewer from '../diff-viewer';
import MarkedLines from '../marked-lines';
import PaidAccordion, { PaidAccordionItem } from '../paid-accordion';
import Pagination from './pagination';