зеркало из https://github.com/mozilla/pontoon.git
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:
Родитель
549cd93298
Коммит
b2d4cbc728
|
@ -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, Ӓ', 'Ӓ'],
|
||||
['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>;
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче