From b2d4cbc728eb097518e5c8e2d4151d2102b02d08 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Thu, 9 Nov 2023 16:58:47 -0800 Subject: [PATCH] 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. --- package-lock.json | 10 - translate/package.json | 1 - translate/public/locale/en-US/translate.ftl | 93 ++----- translate/public/locale/fr/translate.ftl | 77 ++---- .../components/OriginalString.test.js | 2 +- .../components/OriginalString.tsx | 7 +- .../originalstring/components/RichString.tsx | 4 +- .../placeable/components/Highlight.test.js | 192 +++++++++++++- .../placeable/components/Highlight.tsx | 238 ++++++++++++++---- .../search/components/SearchTerms.test.js | 13 - .../modules/search/components/SearchTerms.tsx | 45 ---- translate/src/modules/search/index.ts | 2 - .../components/FluentTranslation.tsx | 21 +- .../components/GenericTranslation.tsx | 17 +- 14 files changed, 433 insertions(+), 289 deletions(-) delete mode 100644 translate/src/modules/search/components/SearchTerms.test.js delete mode 100644 translate/src/modules/search/components/SearchTerms.tsx diff --git a/package-lock.json b/package-lock.json index 4503d823e..bc5d611c6 100644 --- a/package-lock.json +++ b/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", diff --git a/translate/package.json b/translate/package.json index c185c5778..eeb5acb9a 100644 --- a/translate/package.json +++ b/translate/package.json @@ -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", diff --git a/translate/public/locale/en-US/translate.ftl b/translate/public/locale/en-US/translate.ftl index b498fc94f..75a71f149 100644 --- a/translate/public/locale/en-US/translate.ftl +++ b/translate/public/locale/en-US/translate.ftl @@ -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 diff --git a/translate/public/locale/fr/translate.ftl b/translate/public/locale/fr/translate.ftl index 06681b9c7..07009a41e 100644 --- a/translate/public/locale/fr/translate.ftl +++ b/translate/public/locale/fr/translate.ftl @@ -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 diff --git a/translate/src/modules/originalstring/components/OriginalString.test.js b/translate/src/modules/originalstring/components/OriginalString.test.js index 2650284df..fd50fc629 100644 --- a/translate/src/modules/originalstring/components/OriginalString.test.js +++ b/translate/src/modules/originalstring/components/OriginalString.test.js @@ -36,7 +36,7 @@ describe('', () => { 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$/, ); }); diff --git a/translate/src/modules/originalstring/components/OriginalString.tsx b/translate/src/modules/originalstring/components/OriginalString.tsx index 84c8c246b..f2fc73c5a 100644 --- a/translate/src/modules/originalstring/components/OriginalString.tsx +++ b/translate/src/modules/originalstring/components/OriginalString.tsx @@ -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 (

- - {source} - + {source}

); } diff --git a/translate/src/modules/originalstring/components/RichString.tsx b/translate/src/modules/originalstring/components/RichString.tsx index 3a1a30d7c..341fa1471 100644 --- a/translate/src/modules/originalstring/components/RichString.tsx +++ b/translate/src/modules/originalstring/components/RichString.tsx @@ -31,9 +31,7 @@ export function RichString({ - - {handle.current.value} - + {handle.current.value} diff --git a/translate/src/modules/placeable/components/Highlight.test.js b/translate/src/modules/placeable/components/Highlight.test.js index bf95c4b75..567f6aba4 100644 --- a/translate/src/modules/placeable/components/Highlight.test.js +++ b/translate/src/modules/placeable/components/Highlight.test.js @@ -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( - {content} + + {content} + , ); @@ -89,3 +91,189 @@ describe('mark terms', () => { expect(wrapper.find('mark').at(2).hasClass('placeable')); }); }); + +describe('', () => { + 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, John', ''], + ['hello, ', ''], + ['hello, ', ''], + ["hello, ", ""], + ["hello, ", ""], + ['Happy !', ''], + ]; + 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]); + } + }); + } +}); diff --git a/translate/src/modules/placeable/components/Highlight.tsx b/translate/src/modules/placeable/components/Highlight.tsx index b67457683..48c225732 100644 --- a/translate/src/modules/placeable/components/Highlight.tsx +++ b/translate/src/modules/placeable/components/Highlight.tsx @@ -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>(); + +const placeholder = (() => { + // HTML/XML + 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: ( + + + {hidden ? {hidden} : null} + {text} + + + ), + }); + } + + 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: ( + + + {text} + + + ), + }); + } + } + + 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) => ( - - {x} - - ), - }; - 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: ( + + {match[0]} + + ), + }); + } } - - const wrapTag = (tag: Parser['tag']) => (x: string) => { - const el = tag(x); - const mId = el.props['data-mark']; - return mId ? ( - - {cloneElement(el, { className: 'placeable' })} - - ) : ( - cloneElement(el, { key: ++keyCounter }) - ); - }; - Marker = createMarker(rules, wrapTag); - MarkerCache.set(mk, Marker); } - return {children}; + + // Sort by position, prefer longer marks + marks.sort((a, b) => a.index - b.index || b.length - a.length); + + if (search) { + const searchTerms = search.match(/(? m.index + m.length >= next); + if (i === -1) { + i = marks.length; + } + marks.splice(i, 0, { + index: next, + length: term.length, + mark: ( + + {term} + + ), + }); + pos = next + term.length; + } + } + } + + const res: Array = []; + 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}; } diff --git a/translate/src/modules/search/components/SearchTerms.test.js b/translate/src/modules/search/components/SearchTerms.test.js deleted file mode 100644 index f4bd3596c..000000000 --- a/translate/src/modules/search/components/SearchTerms.test.js +++ /dev/null @@ -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( - {'foo (bar)'}, - ); - expect(wrapper.find('mark').text()).toEqual('(bar'); - }); -}); diff --git a/translate/src/modules/search/components/SearchTerms.tsx b/translate/src/modules/search/components/SearchTerms.tsx deleted file mode 100644 index 0ec4aa875..000000000 --- a/translate/src/modules/search/components/SearchTerms.tsx +++ /dev/null @@ -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) => ( - - {x} - - ); - - res = mark(res, rule, tag); - } - - return <>{res}; -} diff --git a/translate/src/modules/search/index.ts b/translate/src/modules/search/index.ts index d7fd1bb34..c53e9f126 100644 --- a/translate/src/modules/search/index.ts +++ b/translate/src/modules/search/index.ts @@ -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'; diff --git a/translate/src/modules/translation/components/FluentTranslation.tsx b/translate/src/modules/translation/components/FluentTranslation.tsx index 31a08efb6..c6ff402d2 100644 --- a/translate/src/modules/translation/components/FluentTranslation.tsx +++ b/translate/src/modules/translation/components/FluentTranslation.tsx @@ -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 ( - - - - ); + return ; } - if (search) { - return ( - - {preview} - - ); - } - - return ( - - {preview} - - ); + return {preview}; } diff --git a/translate/src/modules/translation/components/GenericTranslation.tsx b/translate/src/modules/translation/components/GenericTranslation.tsx index b97fd1b24..69c21bb19 100644 --- a/translate/src/modules/translation/components/GenericTranslation.tsx +++ b/translate/src/modules/translation/components/GenericTranslation.tsx @@ -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 { if (diffTarget) { - return ( - - - - ); + return ; } - if (search) { - return ( - - {content} - - ); - } - - return {content}; + return {content}; }