Replace react-content-marker with a regexp (#3014)

Fixes #2515
Fixes #3006

Ports all the rules from react-content-marker into an inlined implementation. Results are cleaner, more efficient, and less buggy. Also, for placeholders we now match the rules used in the editor highlighting.

In a few places the rules are relaxed a bit and/or made more sane, but these are unlikely to actually effect real-world messages noticably. Except where they fix current bugs. Also, the placeholder rules now match the editor.
This commit is contained in:
Eemeli Aro 2023-11-09 16:58:47 -08:00 коммит произвёл GitHub
Родитель 549cd93298
Коммит b2d4cbc728
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
14 изменённых файлов: 433 добавлений и 289 удалений

10
package-lock.json сгенерированный
Просмотреть файл

@ -11094,15 +11094,6 @@
"react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-content-marker": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/react-content-marker/-/react-content-marker-2.2.0.tgz",
"integrity": "sha512-VV+v3blgxdL/UhxILQnjZYt2J3SPPcCibWizNYW9OmEBIOFB1Xsz1zbOw5gkrxz9a57Hwk4mTZ4Z/KH82hi64w==",
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-dom": {
"version": "16.14.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz",
@ -13058,7 +13049,6 @@
"messageformat": "^4.0.0-2",
"nprogress": "^0.2.0",
"react": "^16.14.0",
"react-content-marker": "^2.2.0",
"react-dom": "^16.14.0",
"react-infinite-scroll-hook": "^4.0.1",
"react-linkify": "^0.2.2",

Просмотреть файл

@ -28,7 +28,6 @@
"messageformat": "^4.0.0-2",
"nprogress": "^0.2.0",
"react": "^16.14.0",
"react-content-marker": "^2.2.0",
"react-dom": "^16.14.0",
"react-infinite-scroll-hook": "^4.0.1",
"react-linkify": "^0.2.2",

Просмотреть файл

@ -1,10 +1,5 @@
### Localization for the Translate page of Pontoon
# Naming convention for l10n IDs: "module-ComponentName--string-summary".
# This allows us to minimize the risk of conflicting IDs throughout the app.
# Please sort alphabetically by (module name, component name). For each module,
# keep strings in order of appearance.
## Pontoon Add-On promotion
## Renders Pontoon Add-On promotion banner
@ -570,75 +565,35 @@ otherlocales-Translation--header-link =
.title = Open string in { $locale } ({ $code })
## Placeable parsers
## Message terms
## Used to mark specific terms and characters in translations.
placeable-parser-altAttribute =
.title = 'alt' attribute inside XML tag
placeable-parser-camelCaseString =
.title = Camel case string
placeable-parser-emailPattern =
.title = Email
placeable-parser-escapeSequence =
.title = Escape sequence
placeable-parser-filePattern =
.title = File location
placeable-parser-fluentFunction =
.title = Fluent function
placeable-parser-fluentParametrizedTerm =
.title = Fluent parametrized term
placeable-parser-fluentString =
.title = Fluent string
placeable-parser-fluentTerm =
.title = Fluent term
placeable-parser-javaFormattingVariable =
.title = Java Message formatting variable
placeable-parser-jsonPlaceholder =
.title = JSON placeholder
placeable-parser-leadingSpace =
.title = Leading space
placeable-parser-multipleSpaces =
.title = Multiple spaces
placeable-parser-narrowNonBreakingSpace =
.title = Narrow non-breaking space
placeable-parser-newlineCharacter =
.title = Newline character
placeable-parser-newlineEscape =
.title = Escaped newline
placeable-parser-nonBreakingSpace =
.title = Non-breaking space
placeable-parser-nsisVariable =
.title = NSIS Variable
placeable-parser-numberString =
.title = Number
placeable-parser-optionPattern =
highlight-cli-option =
.title = Command line option
placeable-parser-punctuation =
highlight-email =
.title = Email
highlight-escape =
.title = Escape sequence
highlight-newline =
.title = Newline character
highlight-number =
.title = Number
highlight-placeholder =
.title = Placeholder
highlight-placeholder-entity =
.title = HTML/XML entity
highlight-placeholder-html =
.title = HTML tag
highlight-placeholder-printf =
.title = Printf format string
highlight-punctuation =
.title = Punctuation
placeable-parser-pythonFormatNamedString =
.title = Python format string
placeable-parser-pythonFormatString =
.title = Python format string
placeable-parser-pythonFormattingVariable =
.title = Python string formatting variable
placeable-parser-qtFormatting =
.title = Qt string formatting variable
placeable-parser-shortCapitalNumberString =
.title = Short capital letter and number string
placeable-parser-stringFormattingVariable =
.title = String formatting variable
placeable-parser-tabCharacter =
highlight-spaces =
.title = Unusual space
highlight-tab =
.title = Tab character
placeable-parser-thinSpace =
.title = Thin space
placeable-parser-unusualSpace =
.title = Unusual space in string
placeable-parser-uriPattern =
.title = URI
placeable-parser-xmlEntity =
.title = XML entity
placeable-parser-xmlTag =
.title = XML tag
highlight-url =
.title = URL
## Project menu

Просмотреть файл

@ -1,10 +1,5 @@
### Localization for the Translate page of Pontoon
# Naming convention for l10n IDs: "module-ComponentName--string-summary".
# This allows us to minimize the risk of conflicting IDs throughout the app.
# Please sort alphabetically by (module name, component name). For each module,
# keep strings in order of appearance.
## Editor Menu
## Allows contributors to modify or propose a translation
@ -186,65 +181,35 @@ otherlocales-Translation--copy =
.title = Copier la traduction
## Placeable parsers
## Message terms
## Used to mark specific terms and characters in translations.
placeable-parser-altAttribute =
.title = Attribut 'alt' dans une balise XML
placeable-parser-camelCaseString =
.title = Chaîne Camel Case
placeable-parser-emailPattern =
highlight-cli-option =
.title = Option de ligne de commande
highlight-email =
.title = Courriel
placeable-parser-escapeSequence =
highlight-escape =
.title = Séquence d'échappement
placeable-parser-filePattern =
.title = Emplacement de fichier
placeable-parser-javaFormattingVariable =
.title = Variable de mise en forme Java Message
placeable-parser-jsonPlaceholder =
.title = Emplacment JSON
placeable-parser-multipleSpaces =
.title = Espaces multiple
placeable-parser-narrowNonBreakingSpace =
.title = Espace insécable fine
placeable-parser-newlineCharacter =
highlight-newline =
.title = Caractère de nouvelle ligne
placeable-parser-newlineEscape =
.title = Caractère de nouvelle ligne échappé
placeable-parser-nonBreakingSpace =
.title = Espace insécable
placeable-parser-nsisVariable =
.title = Variable NSIS
placeable-parser-numberString =
highlight-number =
.title = Nombre
placeable-parser-optionPattern =
.title = Otion de ligne de commande
placeable-parser-punctuation =
highlight-placeholder =
.title = Emplacement
highlight-placeholder-entity =
.title = Entité HTML/XML
highlight-placeholder-html =
.title = Balise HTML
highlight-placeholder-printf =
.title = Chaîne de mise en forme Printf
highlight-punctuation =
.title = Ponctuation
placeable-parser-pythonFormatNamedString =
.title = Chaîne de mise en forme Python
placeable-parser-pythonFormatString =
.title = Chaîne de mise en forme Python
placeable-parser-pythonFormattingVariable =
.title = Variable de chaîne de mise en forme Python
placeable-parser-qtFormatting =
.title = Variable de chaîne de mise en forme Qt
placeable-parser-stringFormattingVariable =
.title = Variable de chaîne de mise en forme
placeable-parser-shortCapitalNumberString =
.title = Chaîne courte de majuscules et chiffres
placeable-parser-tabCharacter =
highlight-spaces =
.title = Espace inhabituelle
highlight-tab =
.title = Caractère tabulation
placeable-parser-thinSpace =
.title = Espace fine
placeable-parser-unusualSpace =
.title = Espace inhabituelle dans la chaîne
placeable-parser-uriPattern =
.title = URI
placeable-parser-xmlEntity =
.title = Entité XML
placeable-parser-xmlTag =
.title = Balise XML
highlight-url =
.title = URL
## Resource menu

Просмотреть файл

@ -36,7 +36,7 @@ describe('<OriginalString>', () => {
it('renders original input as simple string', () => {
const wrapper = mountOriginalString();
expect(wrapper.find('.original').children().children().text()).toMatch(
expect(wrapper.find('.original').children().text()).toMatch(
/^Hello\W*\nSimple\W*\nString$/,
);
});

Просмотреть файл

@ -65,7 +65,8 @@ export function OriginalString({
const markedTerm = target.dataset['match'];
if (markedTerm) {
setPopupTerms(
terms.terms?.filter((t) => t.text === markedTerm) ?? [],
terms.terms?.filter((t) => t.text.toLowerCase() === markedTerm) ??
[],
);
}
}
@ -115,9 +116,7 @@ function InnerOriginalString({
return (
<p className='original' onClick={onClick}>
<Highlight fluent={isFluent} terms={terms}>
{source}
</Highlight>
<Highlight terms={terms}>{source}</Highlight>
</p>
);
}

Просмотреть файл

@ -31,9 +31,7 @@ export function RichString({
</td>
<td>
<span>
<Highlight fluent terms={terms}>
{handle.current.value}
</Highlight>
<Highlight terms={terms}>{handle.current.value}</Highlight>
</span>
</td>
</tr>

Просмотреть файл

@ -3,10 +3,12 @@ import { mount } from 'enzyme';
import { MockLocalizationProvider } from '~/test/utils';
import { Highlight } from './Highlight';
const mountMarker = (content, terms = []) =>
const mountMarker = (content, terms = [], search = null) =>
mount(
<MockLocalizationProvider>
<Highlight terms={terms}>{content}</Highlight>
<Highlight search={search} terms={terms}>
{content}
</Highlight>
</MockLocalizationProvider>,
);
@ -89,3 +91,189 @@ describe('mark terms', () => {
expect(wrapper.find('mark').at(2).hasClass('placeable'));
});
});
describe('<Highlight search>', () => {
it('does not break on regexp special characters', () => {
const wrapper = mountMarker('foo (bar)', [], '(bar');
expect(wrapper.find('mark.search').text()).toEqual('(bar');
});
it('does not mark search if empty', () => {
const wrapper = mountMarker('123456', [], '');
const marks = wrapper.find('mark');
expect(marks).toHaveLength(1);
expect(marks.at(0).hasClass('placeable'));
expect(marks.at(0).text()).toEqual('123456');
});
it('prefers search marks over placeholders at start of mark', () => {
const wrapper = mountMarker('123456', [], '123');
const marks = wrapper.find('mark');
expect(marks).toHaveLength(1);
expect(marks.at(0).hasClass('search'));
expect(marks.at(0).text()).toEqual('123');
});
it('prefers search marks over placeholders at end of mark', () => {
const wrapper = mountMarker('123456', [], '456');
const marks = wrapper.find('mark');
expect(marks).toHaveLength(1);
expect(marks.at(0).hasClass('search'));
expect(marks.at(0).text()).toEqual('456');
});
});
describe('specific marker', () => {
const tests = [
// email
['Hello lisa@example.org', 'lisa@example.org'],
['Hello mailto:lisa@name.me', 'mailto:lisa@name.me'],
// escapes
['hello,\\tworld', '\\t'],
['\\n', '\\n'],
// Fluent
['Hello {COPY()}', '{COPY()}'],
['Hello { DATETIME($date) }', '{ DATETIME($date) }'],
[
'Hello { NUMBER($ratio, minimumFractionDigits: 2) }',
'{ NUMBER($ratio, minimumFractionDigits: 2) }',
],
[
'Hello { DATETIME($date) } and { COPY() }',
'{ DATETIME($date) }',
'{ COPY() }',
],
['Hello {-brand(case: "test")}', '{-brand(case: "test")}'],
['Hello { -brand(case: "what ever") }', '{ -brand(case: "what ever") }'],
[
'Hello { -brand-name(foo-bar: "now that\'s a value!") }',
'{ -brand-name(foo-bar: "now that\'s a value!") }',
],
[
'Hello {-brand(case: "test")} and {-vendor(case: "right")}',
'{-brand(case: "test")}',
'{-vendor(case: "right")}',
],
['Hello {""}', '{""}'],
['Hello { "" }', '{ "" }'],
['Hello { "world!" }', '{ "world!" }'],
['Hello { "hello!" } from { "world!" }', '{ "hello!" }', '{ "world!" }'],
['Hello {-brand}', '{-brand}'],
['Hello { -brand }', '{ -brand }'],
['Hello { -brand-name }', '{ -brand-name }'],
['Hello {-brand} from {-vendor}', '{-brand}', '{-vendor}'],
// ICU MessageFormat
[
'At {1,time} on {1,date}, there was {2} on planet {0,number,integer}.',
'{1,time}',
'{1,date}',
'{2}',
'{0,number,integer}',
],
// Web extensions
['Hello $USER$', '$USER$'],
['Hello $USER1$', '$USER1$'],
['Hello $FIRST_NAME$', '$FIRST_NAME$'],
['Hello USER$'],
// spaces
[' hello world', ' '],
['hello world ', ' '],
['hello world', ' '],
['hello, world', ' '],
['hello,\u00A0world', '\u00A0'],
['hello,\u2009world', '\u2009'],
['hello,\u202Fworld', '\u202F'],
[`hello,\n world`, '¶\n', ' '],
['hello,\tworld', ' \u2192\t'],
// NSIS
['$Brand', '$Brand'],
['Welcome to $BrandName', '$BrandName'],
['I am $MyVar13', '$MyVar13'],
// numbers
['Here is a 25 number', '25'],
['Here is a -25 number', '-25'],
['Here is a +25 number', '+25'],
['Here is a 25.00 number', '25.00'],
['Here is a 2,500.00 number', '2,500.00'],
['Here is a 1\u00A0000,99 number', '1\u00A0000,99'],
['3D game'],
// CLI options
['Type --help for this help', '--help'],
['Short -S ones also', '-S'],
// punctuation
['Pontoon™', '™'],
['9℉ OMG so cold', '9', '℉'],
['She had π cats', 'π'],
['Please use the correct quote: ʼ', 'ʼ'],
['Here comes the French: «', '«'],
['Gimme the €', '€'],
['Downloading…', '…'],
['Hello — Lisa', '—'],
['Hello – Lisa', '–'],
['Hello\u202Fworld', ''],
['These, are not. Special: punctuation; marks! Or are "they"?'],
// printf
['Hello %(name)s', '%(name)s'],
['Rolling %(number)d dices', '%(number)d'],
['Hello %(name)S', '%(name)S'],
['hello, {0}', '{0}'],
['hello, {name}', '{name}'],
['hello, {name!s}', '{name!s}'],
['hello, {someone.name}', '{someone.name}'],
['hello, {name[0]}', '{name[0]}'],
['100%% correct', '100', '%%'],
['There were %s', '%s'],
['There were %(number)d cows', '%(number)d'],
['There were %(cows.number)d cows', '%(cows.number)d'],
['There were %(number of cows)d cows', '%(number of cows)d'],
['There were %(number)03d cows', '%(number)03d'],
['There were %(number)*d cows', '%(number)*d'],
['There were %(number)3.1d cows', '%(number)3.1d'],
['There were %(number)Ld cows', '%(number)Ld'],
['path/to/file_%s.png', '%s'],
['path/to/%sfile.png', '%s'],
['There were %d cows', '%d'],
['There were %Id cows', '%Id'],
['There were %d %s', '%d', '%s'],
['%1$s was kicked by %2$s', '%1$s', '%2$s'],
['There were %Id cows', '%Id'],
["There were %'f cows", "%'f"],
['There were %#x cows', '%#x'],
['There were %3d cows', '%3d'],
['There were %33d cows', '%33d'],
['There were %*d cows', '%*d'],
['There were %1$d cows', '%1$d'],
['There were %\u00a0d cows', '\u00a0'],
['10 % complete', '10'],
['There were % d cows'],
['There were %(number) 3d cows'],
['Verified by %@', '%@'],
['Update login %1$@ for %2$@?', '%1$@', '%2$@'],
// Qt
['Hello, %1', '%1'],
['Hello, %99', '%99'],
['Hello, %L1', '%L1'],
// XML entities
['Welcome to &brandShortName;', '&brandShortName;'],
['hello, &#1234;', '&#1234;'],
['hello, &xDEAD;', '&xDEAD;'],
// XML tags
['hello, <user>John', '<user>'],
['hello, </user>', '</user>'],
['hello, <user name="John">', '<user name="John">'],
["hello, <user name='John'>", "<user name='John'>"],
["hello, <user data-name='John'>", "<user data-name='John'>"],
['Happy <User.Birthday>!', '<User.Birthday>'],
];
for (const [source, ...exp] of tests) {
test(source, () => {
const wrapper = mountMarker(source);
const marks = wrapper.find('mark');
expect(marks).toHaveLength(exp.length);
for (let i = 0; i < exp.length; ++i) {
expect(marks.at(i).hasClass('placeable'));
expect(marks.at(i).text()).toEqual(exp[i]);
}
});
}
});

Просмотреть файл

@ -1,71 +1,211 @@
import { Localized } from '@fluent/react';
import escapeRegExp from 'lodash.escaperegexp';
import { cloneElement, ReactNode } from 'react';
import createMarker, { getRules, Parser } from 'react-content-marker';
import React from 'react';
import { TermState } from '~/modules/terms';
import './Highlight.css';
import { ReactElement } from 'react';
let keyCounter = 0;
const MarkerCache = new Map<string, ReturnType<typeof createMarker>>();
const placeholder = (() => {
// HTML/XML <tags>
const html = '<(?:(?:"[^"]*")|(?:\'[^\']*\')|[^>])*>';
// Fluent & other similar {placeholders}
const curly = '{(?:(?:"[^"]*")|[^}])*}';
// All printf-ish formats, including Python's.
// Excludes Python's ` ` conversion flag, due to false positives -- https://github.com/mozilla/pontoon/issues/2988
const printf =
"%(?:\\d\\$|\\(.*?\\))?[-+0'#I]*[\\d*]*(?:\\.[\\d*])?(?:hh?|ll?|[jLtz])?[%@AaCcdEeFfGginopSsuXx]";
// Qt placeholders -- https://doc.qt.io/qt-5/qstring.html#arg
const qt = '%L?\\d{1,2}';
// HTML/XML &entities;
const entity = '&(?:[\\w.-]+|#(?:\\d{1,5}|x[a-fA-F0-9]{1,5})+);';
// $foo$ and $bar styles, used e.g. in webextension localization
const dollar = '\\$\\w+\\$?';
// all whitespace except for usual single spaces within the message
const spaces = '[\n\t]|(?:^| )[^\\S\n\t]+|[^\\S\n\t]+$|[^\\S \n\t]+';
// punctuation
const punct = '[™©®℃℉°±πθ×÷−√∞∆Σ′″‘’ʼ‚‛“”„‟«»£¥€…—–]+';
// \ escapes
const escape = '\\\\(?:[nrt\'"\\\\]|u[0-9A-Fa-f]{4}|(?=\\n))';
// Command-line --options and -s shorthands
const cli = '\\B(?:-\\w|--[\\w-]+)\\b';
// URL
const url =
// protocol host port path & query end
'(?:ftp|https?)://\\w[\\w.]*\\w(?::\\d{1,5})?(?:/[\\w$.+!*(),;:@&=?/~#%-]*)?(?=$|[\\s\\]\'"}>),.])';
return new RegExp(
[html, curly, printf, qt, entity, dollar, spaces, punct, escape, cli, url]
.map((x) => `(?:${x})`)
.join('|'),
'g',
);
})();
/**
* Component that marks placeables and terms in a string.
*/
export function Highlight({
children,
fluent = false,
leadingSpaces = false,
search,
terms,
}: {
children: ReactNode | ReactNode[];
fluent?: boolean;
leadingSpaces?: boolean;
children: string;
search?: string | null;
terms?: TermState;
}) {
let textTerms: string[] = [];
if (terms && !terms.fetching && terms.terms) {
textTerms = terms.terms
const source = String(children);
const marks: Array<{
index: number;
length: number;
mark: ReactElement;
}> = [];
for (const match of source.matchAll(placeholder)) {
let l10nId: string;
let hidden = '';
const text = match[0];
switch (text[0]) {
case '<':
l10nId = 'highlight-placeholder-html';
break;
case '{':
case '$':
l10nId = 'highlight-placeholder';
break;
case '%':
l10nId = 'highlight-placeholder-printf';
break;
case '&':
l10nId = 'highlight-placeholder-entity';
break;
case '\\':
l10nId = 'highlight-escape';
break;
case '-':
l10nId = 'highlight-cli-option';
break;
case 'f':
case 'h':
l10nId = 'highlight-url';
break;
case '\n':
l10nId = 'highlight-newline';
hidden = '¶';
break;
case '\t':
l10nId = 'highlight-tab';
hidden = ' →';
break;
default:
l10nId = /^\s/.test(text)
? 'highlight-spaces'
: 'highlight-punctuation';
}
marks.push({
index: match.index ?? -1,
length: text.length,
mark: (
<Localized id={l10nId} attrs={{ title: true }} key={++keyCounter}>
<mark className='placeable' data-match={text}>
{hidden ? <span aria-hidden>{hidden}</span> : null}
{text}
</mark>
</Localized>
),
});
}
for (const { l10nId, re } of [
{ l10nId: 'highlight-email', re: /(?:mailto:)?\w[\w.-]*@\w[\w.]*\w/g },
{ l10nId: 'highlight-number', re: /[-+]?\d+(?:[\u00A0.,]\d+)*\b/gu },
]) {
for (const match of source.matchAll(re)) {
const text = match[0];
marks.push({
index: match.index ?? -1,
length: text.length,
mark: (
<Localized id={l10nId} attrs={{ title: true }} key={++keyCounter}>
<mark className='placeable' data-match={text}>
{text}
</mark>
</Localized>
),
});
}
}
const lcSource = source.toLowerCase();
if (terms?.terms && !terms.fetching) {
const sourceTerms = terms.terms
.filter((t) => lcSource.includes(t.text.toLowerCase()))
.map((t) => t.text)
.sort((a, b) => (a.length < b.length ? 1 : -1));
}
const mk = [fluent, leadingSpaces].join() + '|' + textTerms.join();
let Marker = MarkerCache.get(mk);
if (!Marker) {
const rules = getRules({ fluent, leadingSpaces });
// Sort terms by length descendingly. That allows us to mark multi-word terms
// when they consist of words that are terms as well. See test case for the example.
for (let term of textTerms) {
const text = escapeRegExp(term);
const termParser = {
rule: new RegExp(`\\b${text}[a-zA-z]*\\b`, 'gi'),
tag: (x: string) => (
<mark className='term' data-match={term}>
{x}
</mark>
),
};
rules.push(termParser);
for (const term of sourceTerms) {
const re = new RegExp(`\\b${escapeRegExp(term)}[a-zA-z]*\\b`, 'gi');
for (const match of source.matchAll(re)) {
marks.push({
index: match.index ?? -1,
length: match[0].length,
mark: (
<mark className='term' data-match={term} key={++keyCounter}>
{match[0]}
</mark>
),
});
}
}
const wrapTag = (tag: Parser['tag']) => (x: string) => {
const el = tag(x);
const mId = el.props['data-mark'];
return mId ? (
<Localized
id={`placeable-parser-${mId}`}
attrs={{ title: true }}
key={++keyCounter}
>
{cloneElement(el, { className: 'placeable' })}
</Localized>
) : (
cloneElement(el, { key: ++keyCounter })
);
};
Marker = createMarker(rules, wrapTag);
MarkerCache.set(mk, Marker);
}
return <Marker>{children}</Marker>;
// Sort by position, prefer longer marks
marks.sort((a, b) => a.index - b.index || b.length - a.length);
if (search) {
const searchTerms = search.match(/(?<!\\)"(?:\\"|[^"])+(?<!\\)"|\S+/g);
for (let term of searchTerms ?? []) {
if (term.startsWith('"') && term.endsWith('"')) {
term = term.slice(1, -1);
}
let lcTerm = term.toLowerCase();
let pos = 0;
let next: number;
while ((next = lcSource.indexOf(lcTerm, pos)) !== -1) {
let i = marks.findIndex((m) => m.index + m.length >= next);
if (i === -1) {
i = marks.length;
}
marks.splice(i, 0, {
index: next,
length: term.length,
mark: (
<mark className='search' key={++keyCounter}>
{term}
</mark>
),
});
pos = next + term.length;
}
}
}
const res: Array<string | ReactElement> = [];
let pos = 0;
for (const { index, length, mark } of marks) {
if (index > pos) {
res.push(source.slice(pos, index));
}
if (index >= pos) {
res.push(mark);
pos = index + length;
}
}
if (pos < source.length) {
res.push(source.slice(pos));
}
return <>{res}</>;
}

Просмотреть файл

@ -1,13 +0,0 @@
import React from 'react';
import { mount } from 'enzyme';
import { SearchTerms } from './SearchTerms';
describe('SearchTerms', () => {
it('does not break on regexp special characters', () => {
const wrapper = mount(
<SearchTerms search='(bar'>{'foo (bar)'}</SearchTerms>,
);
expect(wrapper.find('mark').text()).toEqual('(bar');
});
});

Просмотреть файл

@ -1,45 +0,0 @@
import escapeRegExp from 'lodash.escaperegexp';
import React from 'react';
import { mark } from 'react-content-marker';
let keyCounter = 0;
export function SearchTerms({
children,
search,
}: {
children: string;
search: string;
}): React.ReactElement {
// Split search string on spaces except if between non-escaped quotes.
const unusable = '☠';
const searchTerms = search
.replace(/\\"/g, unusable)
.match(/[^\s"]+|"[^"]+"/g);
if (!searchTerms) {
return <>{children}</>;
}
const reg = new RegExp(unusable, 'g');
for (let i = searchTerms.length - 1; i >= 0; --i) {
searchTerms[i] = searchTerms[i].replace(/^["]|["]$/g, '').replace(reg, '"');
}
// Sort array in decreasing order of string length
searchTerms.sort((a, b) => b.length - a.length);
let res: React.ReactNode[] = [children];
for (let searchTerm of searchTerms) {
const rule = new RegExp(escapeRegExp(searchTerm), 'i');
const tag = (x: string) => (
<mark className='search' key={++keyCounter}>
{x}
</mark>
);
res = mark(res, rule, tag);
}
return <>{res}</>;
}

Просмотреть файл

@ -1,5 +1,3 @@
export { SearchTerms } from './components/SearchTerms';
export { SEARCH } from './reducer';
export type { SearchFilters as SearchAndFilters } from './reducer';
export type { TimeRangeType } from './components/SearchBox';

Просмотреть файл

@ -3,7 +3,6 @@ import React from 'react';
import { TranslationDiff } from '~/modules/diff';
import { Highlight } from '~/modules/placeable/components/Highlight';
import { getSimplePreview } from '~/utils/message';
import { SearchTerms } from '~/modules/search';
import type { TranslationProps } from './GenericTranslation';
@ -16,24 +15,8 @@ export function FluentTranslation({
if (diffTarget) {
const fluentTarget = getSimplePreview(diffTarget);
return (
<Highlight fluent>
<TranslationDiff base={fluentTarget} target={preview} />
</Highlight>
);
return <TranslationDiff base={fluentTarget} target={preview} />;
}
if (search) {
return (
<Highlight fluent>
<SearchTerms search={search}>{preview}</SearchTerms>
</Highlight>
);
}
return (
<Highlight fluent leadingSpaces>
{preview}
</Highlight>
);
return <Highlight search={search}>{preview}</Highlight>;
}

Просмотреть файл

@ -2,7 +2,6 @@ import React from 'react';
import { TranslationDiff } from '~/modules/diff';
import { Highlight } from '~/modules/placeable/components/Highlight';
import { SearchTerms } from '~/modules/search';
export type TranslationProps = {
content: string;
@ -16,20 +15,8 @@ export function GenericTranslation({
search,
}: TranslationProps): React.ReactElement<React.ElementType> {
if (diffTarget) {
return (
<Highlight>
<TranslationDiff base={diffTarget} target={content} />
</Highlight>
);
return <TranslationDiff base={diffTarget} target={content} />;
}
if (search) {
return (
<Highlight>
<SearchTerms search={search}>{content}</SearchTerms>
</Highlight>
);
}
return <Highlight leadingSpaces>{content}</Highlight>;
return <Highlight search={search}>{content}</Highlight>;
}