Merge pull request #228 from Pitchouneee/feat/223-text-compare
feat: text compare
This commit is contained in:
Generated
+23
-15
@@ -32,6 +32,8 @@
|
||||
"cron-validator": "^1.3.1",
|
||||
"cronstrue": "^3.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"diff": "^8.0.2",
|
||||
"dompurify": "^3.4.2",
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"formik": "^2.4.6",
|
||||
"heic-to": "^1.4.2",
|
||||
@@ -3536,7 +3538,6 @@
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
@@ -3597,7 +3598,6 @@
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-1.0.6.tgz",
|
||||
"integrity": "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
@@ -5634,10 +5634,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
|
||||
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
|
||||
"dev": true,
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz",
|
||||
"integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
@@ -5759,10 +5758,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.0.tgz",
|
||||
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
|
||||
"optional": true,
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
|
||||
"integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
@@ -5860,7 +5859,7 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
@@ -7803,7 +7802,7 @@
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -9002,6 +9001,16 @@
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/locize-cli/node_modules/diff": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
|
||||
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/locize-cli/node_modules/glob": {
|
||||
"version": "9.3.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz",
|
||||
@@ -9525,7 +9534,6 @@
|
||||
"version": "0.53.0",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.53.0.tgz",
|
||||
"integrity": "sha512-0WNThgC6CMWNXXBxTbaYYcunj08iB5rnx4/G56UOPeL9UVIUGGHA1GR0EWIh9Ebabj7NpCRawQ5b0hfN1jQmYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "^1.0.6"
|
||||
@@ -11732,7 +11740,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sax": {
|
||||
@@ -13226,7 +13234,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
"cron-validator": "^1.3.1",
|
||||
"cronstrue": "^3.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"diff": "^8.0.2",
|
||||
"dompurify": "^3.4.2",
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"formik": "^2.4.6",
|
||||
"heic-to": "^1.4.2",
|
||||
|
||||
@@ -328,13 +328,27 @@
|
||||
"nonSpecialCharPlaceholder": "Encode non-special characters",
|
||||
"title": "Encoding Options"
|
||||
},
|
||||
"inputTitle": "Input String",
|
||||
"resultTitle": "Url-escaped String",
|
||||
"inputTitle": "Input String(URL-escaped)",
|
||||
"resultTitle": "Output string",
|
||||
"toolInfo": {
|
||||
"description": "Load your string and it will automatically get URL-escaped.",
|
||||
"longDescription": "This tool URL-encodes a string. Special URL characters get converted to percent-sign encoding. This encoding is called percent-encoding because each character's numeric value gets converted to a percent sign followed by a two-digit hexadecimal value. The hex values are determined based on the character's codepoint value. For example, a space gets escaped to %20, a colon to %3a, a slash to %2f. Characters that are not special stay unchanged. In case you also need to convert non-special characters to percent-encoding, then we've also added an extra option that lets you do that. Select the encode-non-special-chars option to enable this behavior.",
|
||||
"shortDescription": "Quickly URL-escape a string.",
|
||||
"title": "String URL encoder"
|
||||
}
|
||||
},
|
||||
"textCompare": {
|
||||
"title": "Compare Texts",
|
||||
"description": "Analyze two text blocks to highlight differences, including additions, deletions, and changes.",
|
||||
"shortDescription": "Compare two texts and spot differences instantly",
|
||||
"longDescription": "This tool helps you quickly identify differences between two versions of a text. It highlights inserted, removed, and modified content, making it ideal for reviewing edits, tracking changes, or comparing documents. You can choose the level of precision to detect differences at the word or character level depending on your needs.",
|
||||
"inputTitle": "Input Text",
|
||||
"resultTitle": "Highlighted differences",
|
||||
"options": {
|
||||
"title": "Comparison Settings",
|
||||
"wordLevel": "Word level",
|
||||
"charLevel": "Character level",
|
||||
"levelDesc": "Choose how detailed the comparison should be. Word level highlights changes by words, while character level detects more granular differences."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Box, Typography, CircularProgress, useTheme } from '@mui/material';
|
||||
import InputHeader from '../InputHeader';
|
||||
import ResultFooter from './ResultFooter';
|
||||
import { useContext } from 'react';
|
||||
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
|
||||
import { globalInputHeight } from '../../config/uiConfig';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { stripAndDecodeHtml } from 'utils/string';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
const DOMPURIFY_CONFIG = {
|
||||
ALLOWED_TAGS: ['div', 'span', 'br'],
|
||||
ALLOWED_ATTR: ['class']
|
||||
};
|
||||
|
||||
export default function ToolDiffResult({
|
||||
title = 'Differences',
|
||||
value,
|
||||
loading,
|
||||
isHtml = false
|
||||
}: {
|
||||
title?: string;
|
||||
value: string;
|
||||
loading?: boolean;
|
||||
isHtml?: boolean;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||
|
||||
const handleCopy = () => {
|
||||
const text = isHtml ? stripAndDecodeHtml(value) : value;
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => showSnackBar(t('toolTextResult.copied'), 'success'))
|
||||
.catch((err) =>
|
||||
showSnackBar(t('toolTextResult.copyFailed', { error: err }), 'error')
|
||||
);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const mimeType = isHtml ? 'text/html' : 'text/plain';
|
||||
const blob = new Blob([value], { type: mimeType });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = isHtml ? 'diff-output.html' : 'diff-output.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => window.URL.revokeObjectURL(url), 100);
|
||||
};
|
||||
|
||||
const sharedBoxSx = {
|
||||
p: 2,
|
||||
backgroundColor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
minHeight: '265px'
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<InputHeader title={title} />
|
||||
{loading ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: globalInputHeight
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" sx={{ mt: 2 }}>
|
||||
{t('toolTextResult.loading')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : isHtml ? (
|
||||
<Box
|
||||
sx={{
|
||||
...sharedBoxSx,
|
||||
'& .diff-added': {
|
||||
backgroundColor:
|
||||
theme.palette.mode === 'dark'
|
||||
? theme.palette.success.dark
|
||||
: theme.palette.success.light,
|
||||
color: theme.palette.success.contrastText
|
||||
},
|
||||
'& .diff-removed': {
|
||||
backgroundColor:
|
||||
theme.palette.mode === 'dark'
|
||||
? theme.palette.error.dark
|
||||
: theme.palette.error.light,
|
||||
color: theme.palette.error.contrastText
|
||||
}
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(value, DOMPURIFY_CONFIG)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={sharedBoxSx}>{value}</Box>
|
||||
)}
|
||||
<ResultFooter handleCopy={handleCopy} handleDownload={handleDownload} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,8 @@ import { tool as stringStatistic } from './statistic/meta';
|
||||
import { tool as stringCensor } from './censor/meta';
|
||||
import { tool as stringPasswordGenerator } from './password-generator/meta';
|
||||
import { tool as stringEncodeUrl } from './url-encode/meta';
|
||||
import { tool as StringDecodeUrl } from './url-decode/meta';
|
||||
import { tool as stringDecodeUrl } from './url-decode/meta';
|
||||
import { tool as stringCompare } from './text-compare/meta';
|
||||
import { tool as stringUnicode } from './unicode/meta';
|
||||
|
||||
export const stringTools = [
|
||||
@@ -46,8 +47,9 @@ export const stringTools = [
|
||||
stringCensor,
|
||||
stringPasswordGenerator,
|
||||
stringEncodeUrl,
|
||||
StringDecodeUrl,
|
||||
stringDecodeUrl,
|
||||
stringUnicode,
|
||||
stringHiddenCharacterDetector,
|
||||
stringCompare,
|
||||
stringSlugGenerator
|
||||
];
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Box } from '@mui/material';
|
||||
import { useState, useEffect } from 'react';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import ToolTextInput from '@components/input/ToolTextInput';
|
||||
import ToolDiffResult from '@components/result/ToolDiffResult';
|
||||
import SelectWithDesc from '@components/options/SelectWithDesc';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { compareTextsHtml } from './service';
|
||||
import { level, InitialValuesType } from './types';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
level: 'word'
|
||||
};
|
||||
|
||||
export default function TextCompare({ title }: ToolComponentProps) {
|
||||
const { t } = useTranslation('string');
|
||||
const [inputA, setInputA] = useState<string>('');
|
||||
const [inputB, setInputB] = useState<string>('');
|
||||
const [level, setLevel] = useState<level>('word');
|
||||
const [result, setResult] = useState<string>('');
|
||||
|
||||
const compute = () => {
|
||||
setResult(compareTextsHtml(inputA, inputB, level));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
compute();
|
||||
}, [inputA, inputB, level]);
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: t('textCompare.options.title'),
|
||||
component: (
|
||||
<Box>
|
||||
<SelectWithDesc
|
||||
selected={values.level}
|
||||
options={[
|
||||
{ label: t('textCompare.options.wordLevel'), value: 'word' },
|
||||
{ label: t('textCompare.options.charLevel'), value: 'char' }
|
||||
]}
|
||||
onChange={(value) => {
|
||||
updateField('level', value);
|
||||
setLevel(value);
|
||||
}}
|
||||
description={t('textCompare.options.levelDesc')}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={inputA}
|
||||
inputComponent={
|
||||
<Box display="flex" flexDirection="column" gap={2}>
|
||||
<ToolTextInput
|
||||
value={inputA}
|
||||
onChange={setInputA}
|
||||
title={t('textCompare.inputTitle')}
|
||||
/>
|
||||
<ToolTextInput
|
||||
value={inputB}
|
||||
onChange={setInputB}
|
||||
title={t('textCompare.inputTitle')}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
resultComponent={
|
||||
<ToolDiffResult
|
||||
value={result}
|
||||
title={t('textCompare.resultTitle')}
|
||||
isHtml
|
||||
/>
|
||||
}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
setInput={() => {
|
||||
setInputA('');
|
||||
setInputB('');
|
||||
setResult('');
|
||||
}}
|
||||
compute={compute}
|
||||
toolInfo={{
|
||||
title: t('textCompare.title'),
|
||||
description: t('textCompare.longDescription')
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('string', {
|
||||
i18n: {
|
||||
name: 'string:textCompare.title',
|
||||
description: 'string:textCompare.description',
|
||||
shortDescription: 'string:textCompare.shortDescription',
|
||||
longDescription: 'string:textCompare.longDescription'
|
||||
},
|
||||
path: 'text-compare',
|
||||
icon: 'ic:twotone-difference',
|
||||
keywords: ['text', 'compare'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { diffWordsWithSpace, diffChars } from 'diff';
|
||||
import { level } from './types';
|
||||
import { escapeHtml } from 'utils/string';
|
||||
|
||||
const DIFF_FN = {
|
||||
word: diffWordsWithSpace,
|
||||
char: diffChars
|
||||
} as const;
|
||||
|
||||
export function compareTextsHtml(
|
||||
textA: string,
|
||||
textB: string,
|
||||
level: level
|
||||
): string {
|
||||
const diffs = DIFF_FN[level](textA, textB);
|
||||
|
||||
const html = diffs
|
||||
.map((part) => {
|
||||
const val = escapeHtml(part.value).replace(/\n/g, '<br>');
|
||||
if (part.added) {
|
||||
return `<span class="diff-added">${val}</span>`;
|
||||
}
|
||||
if (part.removed) {
|
||||
return `<span class="diff-removed">${val}</span>`;
|
||||
}
|
||||
return val;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `<div class="diff-line">${html}</div>`;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { compareTextsHtml } from './service';
|
||||
|
||||
describe('compareTextsHtml', () => {
|
||||
it('should highlight added text', () => {
|
||||
const result = compareTextsHtml('Hello world', 'Hello world here', 'word');
|
||||
expect(result).toContain('<span class="diff-added"> here</span>');
|
||||
});
|
||||
|
||||
it('should highlight removed text', () => {
|
||||
const result = compareTextsHtml('Hello world here', 'Hello world', 'word');
|
||||
expect(result).toContain('<span class="diff-removed"> here</span>');
|
||||
});
|
||||
|
||||
it('should highlight changes in the middle of a sentence', () => {
|
||||
const result = compareTextsHtml(
|
||||
'I am in Lyon',
|
||||
'I am in Marseille',
|
||||
'word'
|
||||
);
|
||||
expect(result).toContain('I am in ');
|
||||
expect(result).toContain('<span class="diff-removed">Lyon</span>');
|
||||
expect(result).toContain('<span class="diff-added">Marseille</span>');
|
||||
});
|
||||
|
||||
it('should return plain text for identical inputs', () => {
|
||||
const result = compareTextsHtml(
|
||||
'Same text everywhere',
|
||||
'Same text everywhere',
|
||||
'word'
|
||||
);
|
||||
expect(result).toContain('Same text everywhere');
|
||||
expect(result).not.toContain('diff-added');
|
||||
expect(result).not.toContain('diff-removed');
|
||||
});
|
||||
|
||||
it('should escape HTML characters', () => {
|
||||
const result = compareTextsHtml(
|
||||
'<b>Hello</b>',
|
||||
'<b>Hello world</b>',
|
||||
'word'
|
||||
);
|
||||
expect(result).toContain('<b>Hello');
|
||||
expect(result).toContain('<span class="diff-added"> world</span>');
|
||||
});
|
||||
|
||||
it('should escape & character', () => {
|
||||
const result = compareTextsHtml('AT&T', 'AT&T rocks', 'word');
|
||||
expect(result).toContain('AT&T');
|
||||
});
|
||||
|
||||
it('should wrap result in a diff-line div', () => {
|
||||
const result = compareTextsHtml('foo', 'bar', 'word');
|
||||
expect(result.startsWith('<div class="diff-line">')).toBe(true);
|
||||
expect(result.endsWith('</div>')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty input strings', () => {
|
||||
const result = compareTextsHtml('', '', 'word');
|
||||
expect(result).toBe('<div class="diff-line"></div>');
|
||||
});
|
||||
|
||||
it('should handle only added input', () => {
|
||||
const result = compareTextsHtml('', 'New text', 'word');
|
||||
expect(result).toContain('<span class="diff-added">New text</span>');
|
||||
});
|
||||
|
||||
it('should handle only removed input', () => {
|
||||
const result = compareTextsHtml('Old text', '', 'word');
|
||||
expect(result).toContain('<span class="diff-removed">Old text</span>');
|
||||
});
|
||||
|
||||
it('should convert newlines to <br>', () => {
|
||||
const result = compareTextsHtml('line1\nline2', 'line1\nline2', 'word');
|
||||
expect(result).toContain('<br>');
|
||||
});
|
||||
|
||||
it('should highlight individual characters with char level', () => {
|
||||
const result = compareTextsHtml('color', 'colour', 'char');
|
||||
expect(result).toContain('<span class="diff-added">u</span>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export type level = 'word' | 'char';
|
||||
|
||||
export type InitialValuesType = {
|
||||
level: level;
|
||||
};
|
||||
@@ -138,3 +138,49 @@ export const getI18nNamespaceFromToolCategory = (
|
||||
|
||||
return 'translation';
|
||||
};
|
||||
|
||||
/**
|
||||
* Strips HTML tags from a string and decodes HTML entities into plain text.
|
||||
*
|
||||
* This function parses the provided HTML string using the browser's DOMParser,
|
||||
* extracts the text content, and returns a clean, human-readable string.
|
||||
*
|
||||
* @param html - A string containing HTML markup
|
||||
* @returns A plain text string with all HTML tags removed and entities decoded
|
||||
*
|
||||
* @example
|
||||
* stripAndDecodeHtml('<p>Hello <strong>world</strong></p>')
|
||||
* // returns: 'Hello world'
|
||||
*
|
||||
* @example
|
||||
* stripAndDecodeHtml('<div>Test</div>')
|
||||
* // returns: '<div>Test</div>'
|
||||
*/
|
||||
export function stripAndDecodeHtml(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
return doc.body.textContent ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes special HTML characters in a string to prevent HTML injection.
|
||||
*
|
||||
* Replaces the following characters with their corresponding HTML entities:
|
||||
* - & → &
|
||||
* - < → <
|
||||
* - > → >
|
||||
* - " → "
|
||||
*
|
||||
* @param {string} str - The input string to escape.
|
||||
* @returns {string} The escaped string safe for insertion into HTML.
|
||||
*
|
||||
* @example
|
||||
* escapeHtml('<div class="test">Hello & welcome</div>');
|
||||
* // Returns: '<div class="test">Hello & welcome</div>'
|
||||
*/
|
||||
export function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user