├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── createMarker.test.tsx ├── createMarker.tsx ├── getRules.test.tsx ├── getRules.ts ├── index.ts ├── mark.test.tsx ├── mark.ts ├── markRegExp.test.tsx ├── markRegExp.ts ├── markTerm.test.tsx ├── markTerm.ts └── parsers │ ├── altAttribute.test.tsx │ ├── altAttribute.tsx │ ├── camelCaseString.test.tsx │ ├── camelCaseString.tsx │ ├── emailPattern.test.tsx │ ├── emailPattern.tsx │ ├── escapeSequence.test.tsx │ ├── escapeSequence.tsx │ ├── filePattern.test.tsx │ ├── filePattern.tsx │ ├── fluentFunction.test.tsx │ ├── fluentFunction.tsx │ ├── fluentParametrizedTerm.test.tsx │ ├── fluentParametrizedTerm.tsx │ ├── fluentString.test.tsx │ ├── fluentString.tsx │ ├── fluentTerm.test.tsx │ ├── fluentTerm.tsx │ ├── javaFormattingVariable.test.tsx │ ├── javaFormattingVariable.tsx │ ├── jsonPlaceholder.test.tsx │ ├── jsonPlaceholder.tsx │ ├── leadingSpace.test.tsx │ ├── leadingSpace.tsx │ ├── mozillaPrintfPattern.test.tsx │ ├── mozillaPrintfPattern.tsx │ ├── multipleSpaces.test.tsx │ ├── multipleSpaces.tsx │ ├── narrowNonBreakingSpace.test.tsx │ ├── narrowNonBreakingSpace.tsx │ ├── newlineCharacter.test.tsx │ ├── newlineCharacter.tsx │ ├── newlineEscape.test.tsx │ ├── newlineEscape.tsx │ ├── nonBreakingSpace.test.tsx │ ├── nonBreakingSpace.tsx │ ├── nsisVariable.test.tsx │ ├── nsisVariable.tsx │ ├── numberString.test.tsx │ ├── numberString.tsx │ ├── optionPattern.test.tsx │ ├── optionPattern.tsx │ ├── punctuation.test.tsx │ ├── punctuation.tsx │ ├── pythonFormatNamedString.test.tsx │ ├── pythonFormatNamedString.tsx │ ├── pythonFormatString.test.tsx │ ├── pythonFormatString.tsx │ ├── pythonFormattingVariable.test.tsx │ ├── pythonFormattingVariable.tsx │ ├── qtFormatting.test.tsx │ ├── qtFormatting.tsx │ ├── shortCapitalNumberString.test.tsx │ ├── shortCapitalNumberString.tsx │ ├── stringFormattingVariable.test.tsx │ ├── stringFormattingVariable.tsx │ ├── tabCharacter.test.tsx │ ├── tabCharacter.tsx │ ├── thinSpace.test.tsx │ ├── thinSpace.tsx │ ├── unusualSpace.test.tsx │ ├── unusualSpace.tsx │ ├── uriPattern.test.tsx │ ├── uriPattern.tsx │ ├── xmlEntity.test.tsx │ ├── xmlEntity.tsx │ ├── xmlTag.test.tsx │ └── xmlTag.tsx └── tsconfig.json /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [master] 7 | workflow_dispatch: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-node@v1 15 | with: { node-version: 16.x } 16 | - run: npm ci 17 | - run: npm run build 18 | - run: npm test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | lib/ 3 | 4 | # Node modules 5 | node_modules/ 6 | 7 | # Hidden files 8 | .* 9 | 10 | # Logs 11 | logs 12 | *.log 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | lerna-debug.log* 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.2.0 (December 16, 2022) 4 | 5 | - Add library of built-in parser rules and `getRules()` as an accessor for them 6 | - Add `wrapTag` argument to `createMarker()` 7 | - Build a functional rather than class component from `createMarker()` 8 | - Update dev dependencies & migrate tests from `enzyme` to `@testing-library/react` 9 | 10 | ## 2.1.0 (February 21, 2022) 11 | 12 | - Use `tsc` rather than `babel` for the build 13 | - Replace dependency on `shortid` with a simple counter instead 14 | - Mark peerDependency on `react` more liberally 15 | - Use `ts-jest` & add tests for built code 16 | - Add CI tests with GitHub Actions 17 | - Update dev dependencies 18 | 19 | ## 2.0.0 (April 25, 2021) 20 | 21 | - Convert to TypeScript (#7)[https://github.com/mozilla/react-content-marker/issues/7]. 22 | - Publish `.d.ts` type definitions as part of the package. 23 | 24 | ## 1.1.3 (July 17, 2020) 25 | 26 | - Bumps lodash from 4.17.15 to 4.17.19 (#4)[https://github.com/mozilla/react-content-marker/pull/6]. 27 | 28 | ## 1.1.2 (March 31, 2020) 29 | 30 | - Upgrade dependencies. 31 | 32 | ## 1.1.1 (November 4, 2019) 33 | 34 | - Update vulnerable dependencies. 35 | - Bumps lodash from 4.17.11 to 4.17.15 (#4)[https://github.com/mozilla/react-content-marker/pull/4]. 36 | 37 | ## 1.1.0 (July 17, 2019) 38 | 39 | - Expose `mark` function publicly (#2)[https://github.com/mozilla/react-content-marker/issues/2]. 40 | 41 | ## 1.0.2 (June 28, 2019) 42 | 43 | - Do not mark content out of matching context (#1)[https://github.com/mozilla/react-content-marker/issues/1]. 44 | 45 | ## 1.0.1 (May 8, 2019) 46 | 47 | - Minor README change for npm. 48 | 49 | ## 1.0.0 (May 8, 2019) 50 | 51 | - Initial public release. 52 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Community Participation Guidelines 2 | 3 | This repository is governed by Mozilla's code of conduct and etiquette guidelines. 4 | For more details, please read the 5 | [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). 6 | 7 | ## How to Report 8 | For more information on how to report violations of the Community Participation Guidelines, please read our [How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/) page. 9 | 10 | 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019, Mozilla Foundation 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | * Neither the name of the copyright owner nor the names of its contributors 13 | may be used to endorse or promote products derived from this software 14 | without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Content Marker for React 2 | 3 | `react-content-marker` is a tool to replace content in strings with HTML tags. 4 | It can match simple text, or use the full power of regex. 5 | 6 | **Key features:** 7 | 8 | - Can replace text with anything (other text, any React node). 9 | - Supports any number of parsers (so you can mark several patterns 10 | in the same text easily). 11 | - Works on strings and arrays of strings (it ignores non-string items), 12 | meaning you can combine it with other parsing tools. 13 | - Includes an extensive collection of rules for highlighting text for localization. 14 | 15 | ## Install 16 | 17 | `npm install react-content-marker` 18 | 19 | ## Basic usage 20 | 21 | ```js 22 | import createMarker from 'react-content-marker'; 23 | 24 | const parsers = [ 25 | { 26 | rule: 'world', 27 | tag: x => { x }, 28 | }, 29 | { 30 | rule: /(hello)/i, 31 | tag: x => { x }, 32 | }, 33 | ]; 34 | 35 | const MyMarker = createMarker(parsers); 36 | 37 | render(Hello, world!); 38 | 39 | // Renders: 40 | Hello, world! 41 | ``` 42 | 43 | ## Advanced usage 44 | 45 | ### `createMarker(rules: Parser[], wrapTag?): React.FC<{ children: ReactNode | ReactNode[] }>` 46 | 47 | Takes a list of parser rules and returns a React component. 48 | The string children of that component will have their contents marked according to the given rules. 49 | 50 | Parsers are simple objects. They must define two attributes: `rule` and 51 | `tag`. `rule` is either a string or a regex expressing what is to be matched 52 | in the content. `tag` is a function that takes the matched content and returns 53 | a React Node (a string, null, a React Component, etc. ). 54 | 55 | You can use as many parsers as you want. However, note that once a part of your 56 | input has been marked by a rule, it will be ignored for all following rules. 57 | That means that the order of your parsers is very important. 58 | 59 | When using regex, you will need to have at least one pair of capturing 60 | parentheses, as that is what is used to extract the matched content. If your 61 | regex is complex and uses several capturing parentheses, by default this library 62 | will choose the last non-null match available. If you want to match a different 63 | group, you can define a `matchIndex` attribute in your parser. That integer 64 | will be used to choose the captured group to return. Here are examples: 65 | 66 | If `wrapTag: (tag: Parser['tag']) => Parser['tag']` is defined, 67 | it wraps each `tag` with a common wrapper function. 68 | The default wrapper returns a clone of the element returned by the tag function, 69 | but makes sure that it has a `key` attribute. 70 | 71 | ```js 72 | // Without `matchIndex`. 73 | const parsers = [ 74 | { 75 | rule: /(hello (world|folks))/i, 76 | tag: x => { x }, 77 | }, 78 | ]; 79 | const MyMarker = createMarker(parsers); 80 | render(Hello, world!); 81 | 82 | // Renders: 83 | Hello, world! 84 | ``` 85 | 86 | ```js 87 | // With `matchIndex`. 88 | const parsers = [ 89 | { 90 | rule: /(hello (world|folks))/i, 91 | tag: x => { x }, 92 | matchIndex: 0, 93 | }, 94 | ]; 95 | const MyMarker = createMarker(parsers); 96 | render(Hello, world!); 97 | 98 | // Renders: 99 | Hello, world! 100 | ``` 101 | 102 | ### `getRules({ fluent?: boolean, leadingSpaces?: boolean } = {}): Parser[]` 103 | 104 | ```js 105 | import { getRules } from 'react-content-marker'; 106 | ``` 107 | 108 | Build an array of parser rules from those included in the package: 109 | an extensive set suitable for highlighting localizable text. 110 | Originally built for and used by [Pontoon](https://pontoon.mozilla.org/). 111 | 112 | All options default to `false`: 113 | 114 | - `fluent`: Include rules for [Project Fluent](https://projectfluent.org/) syntax. 115 | - `leadingSpaces`: Include rules for leading spaces. 116 | 117 | All of the included rules mark their matching content with a ``, 118 | where the data field includes a unique identifier for that rule. 119 | When the match is not included directly as its child, 120 | it is included as the value of a `data-match` attribute. 121 | 122 | For a fully custom set of rules, 123 | explore and import the individual rules available under `react-content-marker/lib/parsers/` 124 | and build your own rule array. 125 | 126 | ### `mark(content, rule, tag, matchIndex?): ReactNode[]` 127 | 128 | You can also directly access the `mark` function. That can be useful if you 129 | need to combine different stacks of parsers, and don't want, or cannot, just 130 | merge the lists of rules (which should almost always be a better and simpler 131 | solution). For example, if you want to create a Higher-Order Marker that 132 | combines with another Marker. 133 | 134 | `mark` takes the content to mark and all properties of a rule as parameters, 135 | and outputs the marked content as an array of strings and React nodes. 136 | See its definition: 137 | 138 | ```js 139 | import { mark } from 'react-content-marker'; 140 | 141 | function mark( 142 | content: string | Array, 143 | rule: string | RegExp, 144 | tag: (string) => React.Node, 145 | matchIndex: ?number, 146 | ): Array 147 | ``` 148 | 149 | Note however that this function doesn't perform some of the niceties 150 | `createMarker` does. For example, it doesn't automatically add a `key` to the 151 | tagged elements, which might create warnings in your code. 152 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'jsdom', 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-content-marker", 3 | "version": "2.2.0", 4 | "description": "Content Marker for React", 5 | "repository": "github:mozilla/react-content-marker", 6 | "author": "Adrian Gaudebert ", 7 | "license": "BSD-3-Clause", 8 | "keywords": [ 9 | "react", 10 | "marker", 11 | "highlight", 12 | "mark", 13 | "term", 14 | "text", 15 | "word", 16 | "match", 17 | "occurence", 18 | "substring" 19 | ], 20 | "main": "lib/index.js", 21 | "types": "lib/index.d.ts", 22 | "scripts": { 23 | "build": "tsc", 24 | "test": "jest", 25 | "prepublishOnly": "npm run build && npm test", 26 | "prettier": "prettier --write src/" 27 | }, 28 | "files": [ 29 | "lib", 30 | "!lib/**/*.test.*" 31 | ], 32 | "prettier": { 33 | "arrowParens": "avoid", 34 | "singleQuote": true, 35 | "tabWidth": 4 36 | }, 37 | "peerDependencies": { 38 | "react": ">=16", 39 | "react-dom": ">=16" 40 | }, 41 | "devDependencies": { 42 | "@testing-library/react": "^13.4.0", 43 | "@types/jest": "^29.2.4", 44 | "@types/react": "^18.0.26", 45 | "jest": "^29.3.1", 46 | "jest-environment-jsdom": "^29.3.1", 47 | "prettier": "^2.8.1", 48 | "react": "^18.2.0", 49 | "react-dom": "^18.2.0", 50 | "ts-jest": "^29.0.3", 51 | "typescript": "^4.2.4" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/createMarker.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import { cloneElement } from 'react'; 3 | 4 | import { createMarker } from './createMarker'; 5 | 6 | describe('createMarker', () => { 7 | it('returns a correct component', () => { 8 | const content = 'A horse, a horse, my kingdom for a horse.'; 9 | const parsers = [ 10 | { rule: 'horse', tag: (x: string) => {x} }, 11 | { rule: /(a)/gi, tag: (x: string) => {x} }, 12 | { rule: /king\w+/, tag: (x: string) => {x} }, 13 | ]; 14 | const ContentMarker = createMarker(parsers); 15 | 16 | render({content}); 17 | expect(screen.getAllByText('horse')).toMatchObject([ 18 | { tagName: 'I' }, 19 | { tagName: 'I' }, 20 | { tagName: 'I' }, 21 | ]); 22 | expect(screen.getAllByText(/^[aA]$/)).toMatchObject([ 23 | { tagName: 'B' }, 24 | { tagName: 'B' }, 25 | { tagName: 'B' }, 26 | ]); 27 | expect(screen.getAllByText('kingdom')).toHaveLength(1); 28 | }); 29 | 30 | it('can wrap tags', () => { 31 | const content = 'A horse, a horse, my kingdom for a horse.'; 32 | const parsers = [ 33 | { rule: 'horse', tag: (x: string) => {x} }, 34 | { rule: /(a)/gi, tag: (x: string) => {x} }, 35 | { rule: /king\w+/, tag: (x: string) => {x} }, 36 | ]; 37 | 38 | let counter = 0; 39 | const ContentMarker = createMarker( 40 | parsers, 41 | tag => x => 42 | cloneElement(tag(x), { 'data-foo': 'bar', key: ++counter }) 43 | ); 44 | render({content}); 45 | 46 | const horses = screen.getAllByText('horse'); 47 | expect(horses).toMatchObject([ 48 | { tagName: 'I' }, 49 | { tagName: 'I' }, 50 | { tagName: 'I' }, 51 | ]); 52 | for (const horse of horses) expect(horse.dataset.foo).toEqual('bar'); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/createMarker.tsx: -------------------------------------------------------------------------------- 1 | import { cloneElement, ReactNode } from 'react'; 2 | 3 | import type { Parser } from './index'; 4 | import { mark } from './mark'; 5 | 6 | let keyCounter = 0; 7 | 8 | /** 9 | * Creates a React component class to mark content based on rules. 10 | * 11 | * The component takes its children and marks them using the rules of the 12 | * parsers that are provided. 13 | * 14 | * @param parsers A list of Parser systems. A Parser must 15 | * define a `rule` and a `tag`. The `rule` can be either a string to match 16 | * terms or a RegExp. Note that a RegExp must have parentheses surrounding 17 | * your pattern, otherwise matches won't be captured. The `tag` is a function 18 | * that accepts a string (the matched term or pattern) and returns a React 19 | * element that will replace the match in the output. Parsers can also pass a 20 | * `matchIndex` parameter, a number that will be used when using a RegExp to 21 | * chose which match to pass to the `tag` function. 22 | * 23 | * @param wrapTag If defined, wraps each tag with a common wrapper function. 24 | * The default wrapper returns a clone of the element returned by the tag function, 25 | * but makes sure that it has a `key` attribute. 26 | * 27 | * @returns A functional component that applies `parsers` to its `children`. 28 | */ 29 | export const createMarker = 30 | ( 31 | parsers: Array, 32 | wrapTag?: (tag: Parser['tag']) => Parser['tag'] 33 | ): React.FC<{ children: ReactNode | ReactNode[] }> => 34 | ({ children }) => { 35 | if (!children) { 36 | return null; 37 | } 38 | 39 | wrapTag ??= tag => (x: string) => 40 | cloneElement(tag(x), { key: ++keyCounter }); 41 | 42 | let res: ReactNode[] = Array.isArray(children) ? children : [children]; 43 | for (let parser of parsers) { 44 | const tag = wrapTag(parser.tag); 45 | res = mark(res, parser.rule, tag, parser.matchIndex); 46 | } 47 | 48 | return <>{res}; 49 | }; 50 | -------------------------------------------------------------------------------- /src/getRules.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from './createMarker'; 4 | import { getRules } from './getRules'; 5 | 6 | describe('Test parser order', () => { 7 | it('matches JSON placeholder', () => { 8 | const Marker = createMarker(getRules()); 9 | const content = 'You have created $COUNT$ aliases'; 10 | 11 | const { container } = render({content}); 12 | const marks = container.querySelectorAll('mark'); 13 | expect(marks).toHaveLength(1); 14 | expect(marks[0].textContent).toEqual('$COUNT$'); 15 | }); 16 | }); 17 | 18 | describe('Fluent rules', () => { 19 | each([ 20 | ['Fluent string expression', '{"world"}', 'Hello {"world"}'], 21 | ['Fluent term', '{ -brand-name }', 'Hello { -brand-name }'], 22 | [ 23 | 'Fluent parametrized term', 24 | '{ -count($items) }', 25 | 'We have { -count($items) } things', 26 | ], 27 | [ 28 | 'Fluent function', 29 | '{ COUNT(items: []) }', 30 | 'I have { COUNT(items: []) } things', 31 | ], 32 | ]).it('matches a %s', (_, mark, content) => { 33 | const Marker = createMarker(getRules({ fluent: true })); 34 | const { container } = render({content}); 35 | const marks = container.querySelectorAll('mark'); 36 | expect(marks).toHaveLength(1); 37 | expect(marks[0].textContent).toEqual(mark); 38 | }); 39 | }); 40 | 41 | describe('Leading spaces', () => { 42 | it('matches newlines in a string with leadingSpaces: false', () => { 43 | const Marker = createMarker(getRules({ leadingSpaces: false })); 44 | const content = 'Hello\nworld'; 45 | const { container } = render({content}); 46 | const marks = container.querySelectorAll('mark'); 47 | expect(marks).toHaveLength(1); 48 | expect(marks[0].textContent).toContain('\n'); 49 | }); 50 | 51 | it('matches newlines in a string with leadingSpaces: true', () => { 52 | const Marker = createMarker(getRules({ leadingSpaces: true })); 53 | const content = 'Hello\nworld'; 54 | const { container } = render({content}); 55 | const marks = container.querySelectorAll('mark'); 56 | expect(marks).toHaveLength(1); 57 | expect(marks[0].textContent).toContain('\n'); 58 | }); 59 | 60 | it('does not match spaces at the beginning of a string with leadingSpaces: false', () => { 61 | const Marker = createMarker(getRules({ leadingSpaces: false })); 62 | const content = ' Hello world'; 63 | const { container } = render({content}); 64 | const marks = container.querySelectorAll('mark'); 65 | expect(marks).toHaveLength(0); 66 | }); 67 | 68 | it('matches spaces at the beginning of a string with leadingSpaces: true', () => { 69 | const Marker = createMarker(getRules({ leadingSpaces: true })); 70 | const content = ' Hello world'; 71 | const { container } = render({content}); 72 | const marks = container.querySelectorAll('mark'); 73 | expect(marks).toHaveLength(1); 74 | expect(marks[0].textContent).toEqual(' '); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/getRules.ts: -------------------------------------------------------------------------------- 1 | import type { Parser } from '.'; 2 | import { altAttribute } from './parsers/altAttribute'; 3 | import { camelCaseString } from './parsers/camelCaseString'; 4 | import { emailPattern } from './parsers/emailPattern'; 5 | import { escapeSequence } from './parsers/escapeSequence'; 6 | import { filePattern } from './parsers/filePattern'; 7 | import { fluentFunction } from './parsers/fluentFunction'; 8 | import { fluentParametrizedTerm } from './parsers/fluentParametrizedTerm'; 9 | import { fluentString } from './parsers/fluentString'; 10 | import { fluentTerm } from './parsers/fluentTerm'; 11 | import { javaFormattingVariable } from './parsers/javaFormattingVariable'; 12 | import { jsonPlaceholder } from './parsers/jsonPlaceholder'; 13 | import { leadingSpace } from './parsers/leadingSpace'; 14 | import { mozillaPrintfPattern } from './parsers/mozillaPrintfPattern'; 15 | import { multipleSpaces } from './parsers/multipleSpaces'; 16 | import { narrowNonBreakingSpace } from './parsers/narrowNonBreakingSpace'; 17 | import { newlineCharacter } from './parsers/newlineCharacter'; 18 | import { newlineEscape } from './parsers/newlineEscape'; 19 | import { nonBreakingSpace } from './parsers/nonBreakingSpace'; 20 | import { nsisVariable } from './parsers/nsisVariable'; 21 | import { numberString } from './parsers/numberString'; 22 | import { optionPattern } from './parsers/optionPattern'; 23 | import { punctuation } from './parsers/punctuation'; 24 | import { pythonFormatNamedString } from './parsers/pythonFormatNamedString'; 25 | import { pythonFormatString } from './parsers/pythonFormatString'; 26 | import { pythonFormattingVariable } from './parsers/pythonFormattingVariable'; 27 | import { qtFormatting } from './parsers/qtFormatting'; 28 | import { shortCapitalNumberString } from './parsers/shortCapitalNumberString'; 29 | import { stringFormattingVariable } from './parsers/stringFormattingVariable'; 30 | import { tabCharacter } from './parsers/tabCharacter'; 31 | import { thinSpace } from './parsers/thinSpace'; 32 | import { unusualSpace } from './parsers/unusualSpace'; 33 | import { uriPattern } from './parsers/uriPattern'; 34 | import { xmlEntity } from './parsers/xmlEntity'; 35 | import { xmlTag } from './parsers/xmlTag'; 36 | 37 | /** 38 | * Build an array of parser rules from those included in the package: 39 | * an extensive set suitable for highlighting localizable text. 40 | * Originally built for and used by [Pontoon](https://pontoon.mozilla.org/). 41 | * 42 | * All options default to `false`: 43 | * - `fluent`: Include rules for [Project Fluent](https://projectfluent.org/) syntax. 44 | * - `leadingSpaces`: Include rules for leading spaces. 45 | * 46 | * For a fully custom set of rules, 47 | * explore and import the individual rules available under `react-content-marker/lib/parsers/` 48 | * and build your own rule array. 49 | */ 50 | export function getRules(opt?: { 51 | fluent?: boolean; 52 | leadingSpaces?: boolean; 53 | }): Parser[] { 54 | // Note: the order of these MATTERS! 55 | const rules: Parser[] = [ 56 | newlineEscape, 57 | newlineCharacter, 58 | tabCharacter, 59 | escapeSequence, 60 | ]; 61 | 62 | // The spaces rule can match '\n ' and mask the newline, 63 | // so they have to come later. 64 | if (opt?.leadingSpaces) { 65 | rules.push(leadingSpace, unusualSpace); 66 | } 67 | rules.push( 68 | nonBreakingSpace, 69 | narrowNonBreakingSpace, 70 | thinSpace, 71 | multipleSpaces 72 | ); 73 | 74 | if (opt?.fluent) { 75 | rules.push( 76 | fluentString, 77 | fluentParametrizedTerm, 78 | fluentTerm, 79 | fluentFunction 80 | ); 81 | } 82 | 83 | rules.push( 84 | // The XML rules must be marked before variables 85 | // to avoid marking variables, but leaving out tags. 86 | // See https://bugzilla.mozilla.org/show_bug.cgi?id=1334926 87 | xmlTag, 88 | altAttribute, 89 | xmlEntity, 90 | 91 | pythonFormatNamedString, 92 | pythonFormatString, 93 | pythonFormattingVariable, 94 | javaFormattingVariable, 95 | stringFormattingVariable, 96 | 97 | // JSON Placeholder parser Must come before NSIS Variable parser, 98 | // otherwise JSON Placeholders are marked up without the trailing $ 99 | jsonPlaceholder, 100 | nsisVariable, 101 | 102 | // The Qt variables can consume the %1 in %1$s which will mask a printf 103 | // placeable, so it has to come later. 104 | qtFormatting, 105 | 106 | uriPattern, 107 | filePattern, 108 | emailPattern, 109 | shortCapitalNumberString, 110 | camelCaseString, 111 | optionPattern, 112 | punctuation, 113 | numberString, 114 | 115 | mozillaPrintfPattern, 116 | ); 117 | 118 | return rules; 119 | } 120 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { createMarker as default } from './createMarker'; 2 | export { getRules } from './getRules'; 3 | export { mark } from './mark'; 4 | 5 | export type TagFunction = (input: string) => React.ReactElement; 6 | 7 | export type Parser = { 8 | rule: string | RegExp; 9 | tag: TagFunction; 10 | matchIndex?: number; 11 | }; 12 | -------------------------------------------------------------------------------- /src/mark.test.tsx: -------------------------------------------------------------------------------- 1 | import { mark } from './mark'; 2 | import { markRegExp } from './markRegExp'; 3 | import { markTerm } from './markTerm'; 4 | 5 | describe('mark', () => { 6 | it('works correctly with a `term` rule', () => { 7 | const content = 'A horse, a horse, my kingdom for a horse.'; 8 | const rule = 'horse'; 9 | const tag = (x: string) => {x}; 10 | 11 | const res = mark(content, rule, tag); 12 | const expected = markTerm(content, rule, tag); 13 | 14 | expect(res).toEqual(expected); 15 | }); 16 | 17 | it('works correctly with a `regex` rule', () => { 18 | const content = 'A horse, a horse, my kingdom for a horse.'; 19 | const rule = /(horse)/; 20 | const tag = (x: string) => {x}; 21 | 22 | const res = mark(content, rule, tag); 23 | const expected = markRegExp(content, rule, tag); 24 | 25 | expect(res).toEqual(expected); 26 | }); 27 | 28 | it('works with an array input', () => { 29 | const content = ['Hello, ',
, 'What is your name?']; 30 | const rule = 'name'; 31 | const tag = (x: string) => {x}; 32 | 33 | const res = mark(content, rule, tag); 34 | const expected = [ 35 | 'Hello, ', 36 |
, 37 | 'What is your ', 38 | {'name'}, 39 | '?', 40 | ]; 41 | 42 | expect(res).toEqual(expected); 43 | }); 44 | 45 | it('can chain marks', () => { 46 | const content = 'My name is what my name is who my name is Slim Shady'; 47 | const tag = (x: string) => {x}; 48 | 49 | let res = mark(content, 'name', tag); 50 | res = mark(res, /(wh\w+)/, tag); 51 | res = mark(res, /([A-Z]\w+ [A-Z]\w+)/, tag); 52 | 53 | const expected = [ 54 | 'My ', 55 | name, 56 | ' is ', 57 | what, 58 | ' my ', 59 | name, 60 | ' is ', 61 | who, 62 | ' my ', 63 | name, 64 | ' is ', 65 | Slim Shady, 66 | ]; 67 | 68 | expect(res).toEqual(expected); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/mark.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import type { TagFunction } from './index'; 4 | import { markRegExp } from './markRegExp'; 5 | import { markTerm } from './markTerm'; 6 | 7 | /** 8 | * Replaces matching patterns in a string with markers. 9 | * 10 | * @param content The content to parse and mark. 11 | * 12 | * @param rule The pattern to search and replace in the 13 | * content. 14 | * 15 | * @param tag A function that takes the match string and must return 16 | * a React element. The value returned by that function will 17 | * replace the term in the output. 18 | * 19 | * @param matchIndex The index of the match to use when marking with 20 | * a RegExp. If not provided, will use the last non-null match available. 21 | * 22 | * @returns An array of strings and components, 23 | * similar to the original content but where each matching pattern has been 24 | * replaced by a marking component. 25 | */ 26 | export function mark( 27 | content: string | React.ReactNode[], 28 | rule: string | RegExp, 29 | tag: TagFunction, 30 | matchIndex?: number 31 | ): React.ReactNode[] { 32 | if (!Array.isArray(content)) { 33 | content = [content]; 34 | } 35 | 36 | const output: React.ReactNode[] = []; 37 | for (let part of content) { 38 | if (typeof part === 'string') { 39 | let marked; 40 | if (rule instanceof RegExp) { 41 | marked = markRegExp(part, rule, tag, matchIndex); 42 | } else if (typeof rule === 'string') { 43 | marked = markTerm(part, rule, tag); 44 | } else { 45 | throw Error(`Unsupported rule type for rule ${rule}.`); 46 | } 47 | 48 | output.push(...marked); 49 | } else { 50 | output.push(part); 51 | } 52 | } 53 | 54 | return output; 55 | } 56 | -------------------------------------------------------------------------------- /src/markRegExp.test.tsx: -------------------------------------------------------------------------------- 1 | import { markRegExp } from './markRegExp'; 2 | 3 | describe('markRegExp', () => { 4 | it('correctly marks matches of a simple pattern', () => { 5 | const content = 'A horse, a horse, my kingdom for a horse.'; 6 | 7 | const res = markRegExp(content, /(horse)/, x => {x}); 8 | const expected = [ 9 | 'A ', 10 | {'horse'}, 11 | ', a ', 12 | {'horse'}, 13 | ', my kingdom for a ', 14 | {'horse'}, 15 | '.', 16 | ]; 17 | expect(res).toEqual(expected); 18 | }); 19 | 20 | it('correctly marks matches of a more complex pattern', () => { 21 | const content = 'Foux du fa fa'; 22 | 23 | const res = markRegExp(content, /(f\w+)/i, x => {x}); 24 | const expected = [ 25 | {'Foux'}, 26 | ' du ', 27 | {'fa'}, 28 | ' ', 29 | {'fa'}, 30 | ]; 31 | expect(res).toEqual(expected); 32 | }); 33 | 34 | it('correctly marks unusual spaces', () => { 35 | const content = 'hello world '; 36 | 37 | const res = markRegExp(content, /( +$)/, x => {x}); 38 | const expected = ['hello world', ]; 39 | expect(res).toEqual(expected); 40 | }); 41 | 42 | it('correctly marks the entire content', () => { 43 | const content = 'horse'; 44 | 45 | const res = markRegExp(content, /(horse)/, x => {x}); 46 | const expected = [{'horse'}]; 47 | expect(res).toEqual(expected); 48 | }); 49 | 50 | it('supports attributes in tag', () => { 51 | const content = 'word'; 52 | 53 | const res = markRegExp(content, /(word)/, x => ( 54 | {x} 55 | )); 56 | 57 | const expected = [{'word'}]; 58 | expect(res).toEqual(expected); 59 | }); 60 | 61 | it('returns the input as an array if there is no match', () => { 62 | const content = 'A horse, a horse, my kingdom for a horse.'; 63 | 64 | const res = markRegExp(content, /(missing)/, x => {x}); 65 | 66 | const expected = [content]; 67 | expect(res).toEqual(expected); 68 | }); 69 | 70 | it('supports having several capturing groups in the rule', () => { 71 | const content = 'A horse, a horse, my kingdom for a horse.'; 72 | 73 | const res = markRegExp(content, /(a (horse)|A (horse))/, x => ( 74 | {x} 75 | )); 76 | 77 | const expected = [ 78 | 'A ', 79 | {'horse'}, 80 | ', a ', 81 | {'horse'}, 82 | ', my kingdom for a ', 83 | {'horse'}, 84 | '.', 85 | ]; 86 | expect(res).toEqual(expected); 87 | }); 88 | 89 | it('marks the group defined with matchIndex', () => { 90 | const content = 'A horse, a horse, my kingdom for a horse.'; 91 | 92 | const res = markRegExp( 93 | content, 94 | /(a (horse)|A (horse))/, 95 | x => {x}, 96 | 0 97 | ); 98 | 99 | const expected = [ 100 | {'A horse'}, 101 | ', ', 102 | {'a horse'}, 103 | ', my kingdom for ', 104 | {'a horse'}, 105 | '.', 106 | ]; 107 | expect(res).toEqual(expected); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/markRegExp.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import type { TagFunction } from './index'; 4 | 5 | /** 6 | * Replaces matching patterns in a string with markers. 7 | * 8 | * @param content The content to parse and mark. 9 | * 10 | * @param rule The pattern to search and replace in the content. 11 | * 12 | * @param tag A function that takes the match string and must return 13 | * a React component or a string. The value returned by that function will 14 | * replace the term in the output. 15 | * 16 | * @param matchIndex The index of the match to use when marking with 17 | * a RegExp. If not provided, will use the last non-null match available. 18 | * 19 | * @returns An array of strings and components, 20 | * similar to the original content but where each matching pattern has been 21 | * replaced by a marking component. 22 | */ 23 | export function markRegExp( 24 | content: string, 25 | rule: RegExp, 26 | tag: TagFunction, 27 | matchIndex?: number 28 | ): React.ReactNode[] { 29 | const output: React.ReactNode[] = []; 30 | let remaining = content; 31 | let matches = rule.exec(remaining); 32 | 33 | while (matches) { 34 | let match; 35 | if (typeof matchIndex === 'number') { 36 | match = matches[matchIndex]; 37 | } else { 38 | // Use the last non-empty matching form. This is to support several 39 | // capture groups in the rule. 40 | match = matches.reduce((acc, cur) => cur || acc, ''); 41 | } 42 | 43 | // Take only the part that can contain the match. 44 | const matchingContent = remaining.slice(matches.index); 45 | // Then split only that part. 46 | const [previous] = matchingContent.split(match); 47 | 48 | // Reconstruct everything before the match. 49 | let beginning = remaining.slice(0, matches.index); 50 | if (previous) { 51 | beginning += previous; 52 | } 53 | // Reconstruct everything after the match. 54 | remaining = remaining.slice(beginning.length + match.length); 55 | 56 | // Add the parts that have been parsed to the output. 57 | if (beginning) { 58 | output.push(beginning); 59 | } 60 | output.push(tag(match)); 61 | 62 | // Compute the next step. 63 | matches = rule.exec(remaining); 64 | } 65 | 66 | if (remaining) { 67 | output.push(remaining); 68 | } 69 | 70 | return output; 71 | } 72 | -------------------------------------------------------------------------------- /src/markTerm.test.tsx: -------------------------------------------------------------------------------- 1 | import { markTerm } from './markTerm'; 2 | 3 | describe('markTerm', () => { 4 | it('correctly marks several strings in the content', () => { 5 | const content = 'A horse, a horse, my kingdom for a horse.'; 6 | 7 | const res = markTerm(content, 'horse', x => {x}); 8 | const expected = [ 9 | 'A ', 10 | {'horse'}, 11 | ', a ', 12 | {'horse'}, 13 | ', my kingdom for a ', 14 | {'horse'}, 15 | '.', 16 | ]; 17 | expect(res).toEqual(expected); 18 | }); 19 | 20 | it('correctly marks a string at the beginning of the content', () => { 21 | const content = 'A horse, a horse, my kingdom for a horse.'; 22 | 23 | const res = markTerm(content, 'A', x => {x}); 24 | const expected = [ 25 | {'A'}, 26 | ' horse, a horse, my kingdom for a horse.', 27 | ]; 28 | expect(res).toEqual(expected); 29 | }); 30 | 31 | it('correctly marks a string at the end of the content', () => { 32 | const content = 'A horse, a horse, my kingdom for a horse.'; 33 | 34 | const res = markTerm(content, 'horse.', x => {x}); 35 | const expected = [ 36 | 'A horse, a horse, my kingdom for a ', 37 | {'horse.'}, 38 | ]; 39 | expect(res).toEqual(expected); 40 | }); 41 | 42 | it('correctly marks the entire content', () => { 43 | const content = 'horse'; 44 | 45 | const res = markTerm(content, 'horse', x => {x}); 46 | const expected = [{'horse'}]; 47 | expect(res).toEqual(expected); 48 | }); 49 | 50 | it('supports attributes in tag', () => { 51 | const content = 'word'; 52 | 53 | const res = markTerm(content, content, x => ( 54 | {x} 55 | )); 56 | 57 | const expected = [{'word'}]; 58 | expect(res).toEqual(expected); 59 | }); 60 | 61 | it('returns the input as an array if there is no match', () => { 62 | const content = 'A horse, a horse, my kingdom for a horse.'; 63 | 64 | const res = markTerm(content, 'missing', x => {x}); 65 | 66 | const expected = [content]; 67 | expect(res).toEqual(expected); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/markTerm.ts: -------------------------------------------------------------------------------- 1 | import type { TagFunction } from './index'; 2 | 3 | /** 4 | * Replaces matching terms in a string with markers. 5 | * 6 | * @param content The content to parse and mark. 7 | * 8 | * @param term The term to search and replace in the content. Case sensitive. 9 | * 10 | * @param tag A function that takes the matched term and must return 11 | * a React component or a string. The value returned by that function will 12 | * replace the term in the output. 13 | * 14 | * @returns An array of strings and components, 15 | * similar to the original content but where each matching pattern has been 16 | * replaced by a marking component. 17 | */ 18 | export function markTerm( 19 | content: string, 20 | term: string, 21 | tag: TagFunction 22 | ): React.ReactNode[] { 23 | const parts = content.split(term); 24 | const output: React.ReactNode[] = []; 25 | if (parts[0]) { 26 | output.push(parts[0]); 27 | } 28 | for (let i = 1; i < parts.length; i++) { 29 | output.push(tag(term)); 30 | if (parts[i]) { 31 | output.push(parts[i]); 32 | } 33 | } 34 | return output; 35 | } 36 | -------------------------------------------------------------------------------- /src/parsers/altAttribute.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { createMarker } from '../createMarker'; 3 | 4 | import { altAttribute } from './altAttribute'; 5 | 6 | describe('altAttribute', () => { 7 | it('marks the right parts of a string', () => { 8 | const Marker = createMarker([altAttribute]); 9 | const content = 'alt="hello"'; 10 | 11 | const { container } = render({content}); 12 | const marks = container.querySelectorAll('mark'); 13 | expect(marks).toHaveLength(1); 14 | expect(marks[0].textContent).toEqual('alt="hello"'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/parsers/altAttribute.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks `alt` attributes and their values inside XML tags. 5 | * 6 | * Example matches: 7 | * 8 | * alt="image description" 9 | * ALT="" 10 | * 11 | * Source: 12 | * https://github.com/translate/translate/blob/2.3.1/translate/storage/placeables/general.py#L55 13 | */ 14 | export const altAttribute = { 15 | rule: /(alt=".*?")/i, 16 | tag: x => ( 17 | 18 | {x} 19 | 20 | ), 21 | } satisfies Parser; 22 | -------------------------------------------------------------------------------- /src/parsers/camelCaseString.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { camelCaseString } from './camelCaseString'; 6 | 7 | describe('camelCaseString', () => { 8 | each([ 9 | ['CamelCase', 'Hello CamelCase'], 10 | ['iPod', 'Hello iPod'], 11 | ['DokuWiki', 'Hello DokuWiki'], 12 | ['KBabel', 'Hello KBabel'], 13 | ]).it('marks `%s` in `%s`', (mark, content) => { 14 | const Marker = createMarker([camelCaseString]); 15 | const { container } = render({content}); 16 | const marks = container.querySelectorAll('mark'); 17 | expect(marks).toHaveLength(1); 18 | expect(marks[0].textContent).toEqual(mark); 19 | }); 20 | 21 | each([['_Bug'], ['NOTCAMEL']]).it( 22 | 'does not mark anything in `%s`', 23 | content => { 24 | const Marker = createMarker([camelCaseString]); 25 | const { container } = render({content}); 26 | const marks = container.querySelectorAll('mark'); 27 | expect(marks).toHaveLength(0); 28 | } 29 | ); 30 | }); 31 | -------------------------------------------------------------------------------- /src/parsers/camelCaseString.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks terms following the CamelCase convention. 5 | * 6 | * Example matches: 7 | * 8 | * CamelCase 9 | * LongCamelCasedTerm 10 | * iSomething 11 | * 12 | * Source: 13 | * https://github.com/translate/translate/blob/2.3.1/translate/storage/placeables/general.py#L274 14 | */ 15 | export const camelCaseString = { 16 | rule: /(\b([a-z]+[A-Z]|[A-Z]+[a-z]+[A-Z]|[A-Z]{2,}[a-z])[a-zA-Z0-9]*\b)/, 17 | matchIndex: 0, 18 | tag: x => ( 19 | 20 | {x} 21 | 22 | ), 23 | } satisfies Parser; 24 | -------------------------------------------------------------------------------- /src/parsers/emailPattern.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { emailPattern } from './emailPattern'; 6 | 7 | describe('emailPattern', () => { 8 | each([ 9 | ['lisa@example.org', 'Hello lisa@example.org'], 10 | ['mailto:lisa@name.me', 'Hello mailto:lisa@name.me'], 11 | ]).it('marks `%s` in `%s`', (mark, content) => { 12 | const Marker = createMarker([emailPattern]); 13 | const { container } = render({content}); 14 | const marks = container.querySelectorAll('mark'); 15 | expect(marks).toHaveLength(1); 16 | expect(marks[0].textContent).toEqual(mark); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/parsers/emailPattern.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks terms that look like an email address. Includes an eventual 5 | * "mailto:" scheme if found. 6 | * 7 | * Example matches: 8 | * 9 | * lisa@example.org 10 | * mailto:USER@example.me 11 | * 12 | * Source: 13 | * https://github.com/translate/translate/blob/2.3.1/translate/storage/placeables/general.py#L220 14 | */ 15 | export const emailPattern = { 16 | rule: /(((mailto:)|)[A-Za-z0-9]+[-a-zA-Z0-9._%]*@(([-A-Za-z0-9]+)\.)+[a-zA-Z]{2,4})/, 17 | matchIndex: 0, 18 | tag: x => ( 19 | 20 | {x} 21 | 22 | ), 23 | } satisfies Parser; 24 | -------------------------------------------------------------------------------- /src/parsers/escapeSequence.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { createMarker } from '../createMarker'; 3 | 4 | import { escapeSequence } from './escapeSequence'; 5 | 6 | describe('escapeSequence', () => { 7 | it('marks the right parts of a string', () => { 8 | const Marker = createMarker([escapeSequence]); 9 | const content = 'hello,\\tworld'; 10 | 11 | const { container } = render({content}); 12 | const marks = container.querySelectorAll('mark'); 13 | expect(marks).toHaveLength(1); 14 | expect(marks[0].textContent).toEqual('\\'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/parsers/escapeSequence.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks the escape character "\". 5 | */ 6 | export const escapeSequence = { 7 | rule: '\\', 8 | tag: x => ( 9 | 10 | {x} 11 | 12 | ), 13 | } satisfies Parser; 14 | -------------------------------------------------------------------------------- /src/parsers/filePattern.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { filePattern } from './filePattern'; 6 | 7 | describe('filePattern', () => { 8 | each([ 9 | ['/home', '/home'], 10 | ['/home/lisa', 'Hello /home/lisa'], 11 | ['/home', 'The path /home leads to your home'], 12 | ['~/user', 'Hello ~/user'], 13 | ['/home/homer/budget.md', 'The money is in /home/homer/budget.md'], 14 | ]).it('marks `%s` in `%s`', (mark, content) => { 15 | const Marker = createMarker([filePattern]); 16 | const { container } = render({content}); 17 | const marks = container.querySelectorAll('mark'); 18 | expect(marks).toHaveLength(1); 19 | expect(marks[0].textContent).toEqual(mark); 20 | }); 21 | 22 | each([['Pause/Resume']]).it('does not mark anything in `%s`', content => { 23 | const Marker = createMarker([filePattern]); 24 | const { container } = render({content}); 25 | const marks = container.querySelectorAll('mark'); 26 | expect(marks).toHaveLength(0); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/parsers/filePattern.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks terms that look like a path to a folder or a file. 5 | * 6 | * Example matches: 7 | * 8 | * /home/lisa 9 | * /home/homer/budget.md 10 | * ~/recipies.txt 11 | * 12 | * Source: 13 | * https://github.com/translate/translate/blob/2.3.1/translate/storage/placeables/general.py#L208 14 | */ 15 | export const filePattern = { 16 | rule: /(^|\s)((~\/|\/|\.\/)([-A-Za-z0-9_$.+!*(),;:@&=?/~#%]|\\){3,})/, 17 | matchIndex: 2, 18 | tag: x => ( 19 | 20 | {x} 21 | 22 | ), 23 | } satisfies Parser; 24 | -------------------------------------------------------------------------------- /src/parsers/fluentFunction.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { fluentFunction } from './fluentFunction'; 6 | 7 | describe('fluentFunction', () => { 8 | each([ 9 | ['{COPY()}', 'Hello {COPY()}'], 10 | ['{ DATETIME($date) }', 'Hello { DATETIME($date) }'], 11 | [ 12 | '{ NUMBER($ratio, minimumFractionDigits: 2) }', 13 | 'Hello { NUMBER($ratio, minimumFractionDigits: 2) }', 14 | ], 15 | ]).it('marks `%s` in `%s`', (mark, content) => { 16 | const Marker = createMarker([fluentFunction]); 17 | const { container } = render({content}); 18 | const marks = container.querySelectorAll('mark'); 19 | expect(marks).toHaveLength(1); 20 | expect(marks[0].textContent).toEqual(mark); 21 | }); 22 | 23 | each([ 24 | [ 25 | '{ DATETIME($date) }', 26 | '{ COPY() }', 27 | 'Hello { DATETIME($date) } and { COPY() }', 28 | ], 29 | ]).it('marks `%s` and `%s` in `%s`', (mark1, mark2, content) => { 30 | const Marker = createMarker([fluentFunction]); 31 | const { container } = render({content}); 32 | const marks = container.querySelectorAll('mark'); 33 | expect(marks).toHaveLength(2); 34 | expect(marks[0].textContent).toEqual(mark1); 35 | expect(marks[1].textContent).toEqual(mark2); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/parsers/fluentFunction.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks functions from Fluent syntax. 5 | * 6 | * Documentation: https://projectfluent.org/fluent/guide/functions.html 7 | * 8 | * Example matches: 9 | * 10 | * {COPY()} 11 | * { DATETIME($date) } 12 | * { NUMBER($ratio, minimumFractionDigits: 2) } 13 | */ 14 | export const fluentFunction = { 15 | rule: /({ ?[A-W0-9\-_]+[^}]* ?})/, 16 | tag: x => ( 17 | 18 | {x} 19 | 20 | ), 21 | } satisfies Parser; 22 | -------------------------------------------------------------------------------- /src/parsers/fluentParametrizedTerm.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { fluentParametrizedTerm } from './fluentParametrizedTerm'; 6 | 7 | describe('fluentParametrizedTerm', () => { 8 | each([ 9 | ['{-brand(case: "test")}', 'Hello {-brand(case: "test")}'], 10 | [ 11 | '{ -brand(case: "what ever") }', 12 | 'Hello { -brand(case: "what ever") }', 13 | ], 14 | [ 15 | '{ -brand-name(foo-bar: "now that\'s a value!") }', 16 | 'Hello { -brand-name(foo-bar: "now that\'s a value!") }', 17 | ], 18 | ]).it('marks `%s` in `%s`', (mark, content) => { 19 | const Marker = createMarker([fluentParametrizedTerm]); 20 | const { container } = render({content}); 21 | const marks = container.querySelectorAll('mark'); 22 | expect(marks).toHaveLength(1); 23 | expect(marks[0].textContent).toEqual(mark); 24 | }); 25 | 26 | each([ 27 | [ 28 | '{-brand(case: "test")}', 29 | '{-vendor(case: "right")}', 30 | 'Hello {-brand(case: "test")} and {-vendor(case: "right")}', 31 | ], 32 | ]).it('marks `%s` and `%s` in `%s`', (mark1, mark2, content) => { 33 | const Marker = createMarker([fluentParametrizedTerm]); 34 | const { container } = render({content}); 35 | const marks = container.querySelectorAll('mark'); 36 | expect(marks).toHaveLength(2); 37 | expect(marks[0].textContent).toEqual(mark1); 38 | expect(marks[1].textContent).toEqual(mark2); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/parsers/fluentParametrizedTerm.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks parametrized term expressions from Fluent syntax. 5 | * 6 | * Documentation: https://projectfluent.org/fluent/guide/terms.html#parameterized-terms 7 | * 8 | * Example matches: 9 | * 10 | * {-brand(case: "test")} 11 | * { -brand(case: "what ever") } 12 | * { -brand-name(foo-bar: "now that's a value!") } 13 | */ 14 | export const fluentParametrizedTerm = { 15 | rule: /({ ?-[^}]*([^}]*: ?[^}]*) ?})/, 16 | matchIndex: 1, 17 | tag: x => ( 18 | 22 | {x} 23 | 24 | ), 25 | } satisfies Parser; 26 | -------------------------------------------------------------------------------- /src/parsers/fluentString.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { fluentString } from './fluentString'; 6 | 7 | describe('fluentString', () => { 8 | each([ 9 | ['{""}', 'Hello {""}'], 10 | ['{ "" }', 'Hello { "" }'], 11 | ['{ "world!" }', 'Hello { "world!" }'], 12 | ]).it('marks `%s` in `%s`', (mark, content) => { 13 | const Marker = createMarker([fluentString]); 14 | const { container } = render({content}); 15 | const marks = container.querySelectorAll('mark'); 16 | expect(marks).toHaveLength(1); 17 | expect(marks[0].textContent).toEqual(mark); 18 | }); 19 | 20 | each([ 21 | [ 22 | '{ "hello!" }', 23 | '{ "world!" }', 24 | 'Hello { "hello!" } from { "world!" }', 25 | ], 26 | ]).it('marks `%s` and `%s` in `%s`', (mark1, mark2, content) => { 27 | const Marker = createMarker([fluentString]); 28 | const { container } = render({content}); 29 | const marks = container.querySelectorAll('mark'); 30 | expect(marks).toHaveLength(2); 31 | expect(marks[0].textContent).toEqual(mark1); 32 | expect(marks[1].textContent).toEqual(mark2); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/parsers/fluentString.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks string expressions from Fluent syntax. 5 | * 6 | * Documentation: https://projectfluent.org/fluent/guide/special.html#quoted-text 7 | * 8 | * Example matches: 9 | * 10 | * { "" } 11 | * { "Hello, World" } 12 | */ 13 | export const fluentString = { 14 | rule: /({ ?"[^}]*" ?})/, 15 | tag: x => ( 16 | 17 | {x} 18 | 19 | ), 20 | } satisfies Parser; 21 | -------------------------------------------------------------------------------- /src/parsers/fluentTerm.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { fluentTerm } from './fluentTerm'; 6 | 7 | describe('fluentTerm', () => { 8 | each([ 9 | ['{-brand}', 'Hello {-brand}'], 10 | ['{ -brand }', 'Hello { -brand }'], 11 | ['{ -brand-name }', 'Hello { -brand-name }'], 12 | ]).it('marks `%s` in `%s`', (mark, content) => { 13 | const Marker = createMarker([fluentTerm]); 14 | const { container } = render({content}); 15 | const marks = container.querySelectorAll('mark'); 16 | expect(marks).toHaveLength(1); 17 | expect(marks[0].textContent).toEqual(mark); 18 | }); 19 | 20 | each([['{-brand}', '{-vendor}', 'Hello {-brand} from {-vendor}']]).it( 21 | 'marks `%s` and `%s` in `%s`', 22 | (mark1, mark2, content) => { 23 | const Marker = createMarker([fluentTerm]); 24 | const { container } = render({content}); 25 | const marks = container.querySelectorAll('mark'); 26 | expect(marks).toHaveLength(2); 27 | expect(marks[0].textContent).toEqual(mark1); 28 | expect(marks[1].textContent).toEqual(mark2); 29 | } 30 | ); 31 | }); 32 | -------------------------------------------------------------------------------- /src/parsers/fluentTerm.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks term expressions from Fluent syntax. 5 | * 6 | * Documentation: https://projectfluent.org/fluent/guide/terms.html 7 | * 8 | * Example matches: 9 | * 10 | * {-brand} 11 | * { -brand } 12 | * { -brand-name } 13 | */ 14 | export const fluentTerm = { 15 | rule: /({ ?-[^}]* ?})/, 16 | tag: x => ( 17 | 18 | {x} 19 | 20 | ), 21 | } satisfies Parser; 22 | -------------------------------------------------------------------------------- /src/parsers/javaFormattingVariable.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { javaFormattingVariable } from './javaFormattingVariable'; 6 | 7 | describe('javaFormattingVariable', () => { 8 | each([ 9 | ['{1,time}', 'At {1,time}'], 10 | ['{1,date}', 'on {1,date}, '], 11 | ['{2}', 'there was {2} '], 12 | ['{0,number,integer}', 'n planet {0,number,integer}.'], 13 | ]).it('marks `%s` in `%s`', (mark, content) => { 14 | const Marker = createMarker([javaFormattingVariable]); 15 | const { container } = render({content}); 16 | const marks = container.querySelectorAll('mark'); 17 | expect(marks).toHaveLength(1); 18 | expect(marks[0].textContent).toEqual(mark); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/parsers/javaFormattingVariable.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks Java MessageFormat formatting variables. 5 | * 6 | * Implemented according to the Java MessageFormat documentation — 7 | * https://docs.oracle.com/javase/7/docs/api/java/text/MessageFormat.html 8 | * 9 | * Information about custom formats: 10 | * - number: DecimalFormat — https://docs.oracle.com/javase/7/docs/api/java/text/DecimalFormat.html 11 | * - date/time: SimpleDateFormat — https://docs.oracle.com/javase/7/docs/api/java/text/SimpleDateFormat.html 12 | * - choice: ChoiceFormat — https://docs.oracle.com/javase/7/docs/api/java/text/ChoiceFormat.html 13 | * 14 | * Example matches: 15 | * 16 | * {2} 17 | * {1,date} 18 | * {0,number,integer} 19 | * 20 | * Source: 21 | * https://github.com/translate/translate/blob/2.3.1/translate/storage/placeables/general.py#L127 22 | */ 23 | export const javaFormattingVariable = { 24 | rule: /({[0-9]+(,\s*(number(,\s*(integer|currency|percent|[-0#.,E;%\u2030\u00a4']+)?)?|(date|time)(,\s*(short|medium|long|full|.+?))?|choice,([^{]+({.+})?)+)?)?})/, 25 | matchIndex: 0, 26 | tag: x => ( 27 | 31 | {x} 32 | 33 | ), 34 | } satisfies Parser; 35 | -------------------------------------------------------------------------------- /src/parsers/jsonPlaceholder.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { jsonPlaceholder } from './jsonPlaceholder'; 6 | 7 | describe('jsonPlaceholder', () => { 8 | each([ 9 | ['$USER$', 'Hello $USER$'], 10 | ['$USER1$', 'Hello $USER1$'], 11 | ['$FIRST_NAME$', 'Hello $FIRST_NAME$'], 12 | ]).it('marks `%s` in `%s`', (mark, content) => { 13 | const Marker = createMarker([jsonPlaceholder]); 14 | const { container } = render({content}); 15 | const marks = container.querySelectorAll('mark'); 16 | expect(marks).toHaveLength(1); 17 | expect(marks[0].textContent).toEqual(mark); 18 | }); 19 | 20 | each([['$user$', 'Hello $user$'], ['Hello $USER'], ['Hello USER$']]).it( 21 | 'does not mark anything in `%s`', 22 | content => { 23 | const Marker = createMarker([jsonPlaceholder]); 24 | const { container } = render({content}); 25 | const marks = container.querySelectorAll('mark'); 26 | expect(marks).toHaveLength(0); 27 | } 28 | ); 29 | }); 30 | -------------------------------------------------------------------------------- /src/parsers/jsonPlaceholder.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks JSON format placeholders as used by the WebExtension API. 5 | * 6 | * Terms must start and end with a dollar sign "$" and contain only capital 7 | * letters or underscores. 8 | * 9 | * Example matches: 10 | * 11 | * $USER$ 12 | * $FIRST_NAME$ 13 | */ 14 | export const jsonPlaceholder = { 15 | rule: /(\$[A-Z0-9_]+\$)/, 16 | tag: x => ( 17 | 18 | {x} 19 | 20 | ), 21 | } satisfies Parser; 22 | -------------------------------------------------------------------------------- /src/parsers/leadingSpace.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { leadingSpace } from './leadingSpace'; 6 | 7 | describe('leadingSpace', () => { 8 | each([[' ', ' hello world']]).it('marks `%s` in `%s`', (mark, content) => { 9 | const Marker = createMarker([leadingSpace]); 10 | const { container } = render({content}); 11 | const marks = container.querySelectorAll('mark'); 12 | expect(marks).toHaveLength(1); 13 | expect(marks[0].textContent).toEqual(mark); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/parsers/leadingSpace.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks spaces at the beginning of a string. 5 | * 6 | * Example matches: 7 | * 8 | * " Hello, world" 9 | */ 10 | export const leadingSpace = { 11 | rule: /(^ +)/, 12 | tag: x => ( 13 | 14 | {x} 15 | 16 | ), 17 | } satisfies Parser; 18 | -------------------------------------------------------------------------------- /src/parsers/mozillaPrintfPattern.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { mozillaPrintfPattern } from './mozillaPrintfPattern'; 6 | 7 | describe('mozillaPrintfPattern', () => { 8 | each([ 9 | ['%S', 'My %S is Luka.'], 10 | ['%1$S', 'My %1$S is Luka.'], 11 | ['%@', 'My %@ is Luka.'], 12 | ['%2$@', 'My %2$@ is Luka.'], 13 | ]).it('marks `%s` in `%s`', (mark, content) => { 14 | const Marker = createMarker([mozillaPrintfPattern]); 15 | const { container } = render({content}); 16 | const marks = container.querySelectorAll('mark'); 17 | expect(marks).toHaveLength(1); 18 | expect(marks[0].textContent).toEqual(mark); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/parsers/mozillaPrintfPattern.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks placeables in the format of Mozilla's printf string syntax. 5 | * 6 | * Example matches: 7 | * 8 | * %S 9 | * %1$S 10 | * %@ 11 | * %2$@ 12 | */ 13 | export const mozillaPrintfPattern: Parser = { 14 | rule: /%(?:[1-9]\$)?[S@]/, 15 | matchIndex: 0, 16 | tag: x => ( 17 | 18 | {x} 19 | 20 | ), 21 | }; 22 | -------------------------------------------------------------------------------- /src/parsers/multipleSpaces.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { createMarker } from '../createMarker'; 3 | 4 | import { multipleSpaces } from './multipleSpaces'; 5 | 6 | describe('multipleSpaces', () => { 7 | it('marks the right parts of a string', () => { 8 | const Marker = createMarker([multipleSpaces]); 9 | const content = 'hello, world'; 10 | 11 | const { container } = render({content}); 12 | const marks = container.querySelectorAll('mark'); 13 | expect(marks).toHaveLength(1); 14 | expect(marks[0].textContent).toEqual(' \u00B7 '); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/parsers/multipleSpaces.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks multiple consecutive spaces and replaces them with a middle dot. 5 | */ 6 | export const multipleSpaces = { 7 | rule: /( +)/, 8 | tag: x => ( 9 | 10 | · 11 | 12 | ), 13 | } satisfies Parser; 14 | -------------------------------------------------------------------------------- /src/parsers/narrowNonBreakingSpace.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { createMarker } from '../createMarker'; 3 | 4 | import { narrowNonBreakingSpace } from './narrowNonBreakingSpace'; 5 | 6 | describe('narrowNonBreakingSpace', () => { 7 | it('marks the right parts of a string', () => { 8 | const Marker = createMarker([narrowNonBreakingSpace]); 9 | const content = 'hello,\u202Fworld'; 10 | 11 | const { container } = render({content}); 12 | const marks = container.querySelectorAll('mark'); 13 | expect(marks).toHaveLength(1); 14 | expect(marks[0].textContent).toEqual('\u202F'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/parsers/narrowNonBreakingSpace.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks the narrow no-break space character (Unicode U+202F). 5 | */ 6 | export const narrowNonBreakingSpace = { 7 | rule: /([\u202F])/, 8 | tag: x => ( 9 | 13 | {x} 14 | 15 | ), 16 | } satisfies Parser; 17 | -------------------------------------------------------------------------------- /src/parsers/newlineCharacter.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { createMarker } from '../createMarker'; 3 | 4 | import { newlineCharacter } from './newlineCharacter'; 5 | 6 | describe('newlineCharacter', () => { 7 | it('marks the right parts of a string', () => { 8 | const Marker = createMarker([newlineCharacter]); 9 | const content = `hello, 10 | world`; 11 | 12 | const { container } = render({content}); 13 | const marks = container.querySelectorAll('mark'); 14 | expect(marks).toHaveLength(1); 15 | expect(marks[0].textContent).toEqual('¶\n'); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/parsers/newlineCharacter.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks the newline character "\n". 5 | */ 6 | export const newlineCharacter = { 7 | rule: '\n', 8 | tag: x => ( 9 | 14 | 15 | {x} 16 | 17 | ), 18 | } satisfies Parser; 19 | -------------------------------------------------------------------------------- /src/parsers/newlineEscape.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { createMarker } from '../createMarker'; 3 | 4 | import { newlineEscape } from './newlineEscape'; 5 | 6 | describe('newlineEscape', () => { 7 | it('marks the right parts of a string', () => { 8 | const Marker = createMarker([newlineEscape]); 9 | const content = '\\n'; 10 | 11 | const { container } = render({content}); 12 | const marks = container.querySelectorAll('mark'); 13 | expect(marks).toHaveLength(1); 14 | expect(marks[0].textContent).toEqual('\\n'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/parsers/newlineEscape.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks escaped newline characters. 5 | */ 6 | export const newlineEscape = { 7 | rule: '\\n', 8 | tag: x => ( 9 | 10 | {x} 11 | 12 | ), 13 | } satisfies Parser; 14 | -------------------------------------------------------------------------------- /src/parsers/nonBreakingSpace.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { createMarker } from '../createMarker'; 3 | 4 | import { nonBreakingSpace } from './nonBreakingSpace'; 5 | 6 | describe('nonBreakingSpace', () => { 7 | it('marks the right parts of a string', () => { 8 | const Marker = createMarker([nonBreakingSpace]); 9 | const content = 'hello,\u00A0world'; 10 | 11 | const { container } = render({content}); 12 | const marks = container.querySelectorAll('mark'); 13 | expect(marks).toHaveLength(1); 14 | expect(marks[0].textContent).toEqual('\u00A0'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/parsers/nonBreakingSpace.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks the no-break space character (Unicode U+00A0). 5 | */ 6 | export const nonBreakingSpace = { 7 | rule: '\u00A0', 8 | tag: x => ( 9 | 10 | {x} 11 | 12 | ), 13 | } satisfies Parser; 14 | -------------------------------------------------------------------------------- /src/parsers/nsisVariable.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { nsisVariable } from './nsisVariable'; 6 | 7 | describe('nsisVariable', () => { 8 | each([ 9 | ['$Brand', '$Brand'], 10 | ['$BrandName', 'Welcome to $BrandName'], 11 | ['$MyVar13', 'I am $MyVar13'], 12 | ]).it('marks `%s` in `%s`', (mark, content) => { 13 | const Marker = createMarker([nsisVariable]); 14 | const { container } = render({content}); 15 | const marks = container.querySelectorAll('mark'); 16 | expect(marks).toHaveLength(1); 17 | expect(marks[0].textContent).toEqual(mark); 18 | }); 19 | 20 | each([['$10'], ['foo$bar']]).it( 21 | 'does not mark anything in `%s`', 22 | content => { 23 | const Marker = createMarker([nsisVariable]); 24 | const { container } = render({content}); 25 | const marks = container.querySelectorAll('mark'); 26 | expect(marks).toHaveLength(0); 27 | } 28 | ); 29 | }); 30 | -------------------------------------------------------------------------------- /src/parsers/nsisVariable.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks NSIS variables. 5 | * 6 | * Example matches: 7 | * 8 | * $Brand 9 | * $BrandShortName 10 | */ 11 | export const nsisVariable = { 12 | rule: /(^|\s)(\$[a-zA-Z][\w]*)/, 13 | matchIndex: 2, 14 | tag: x => ( 15 | 16 | {x} 17 | 18 | ), 19 | } satisfies Parser; 20 | -------------------------------------------------------------------------------- /src/parsers/numberString.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { numberString } from './numberString'; 6 | 7 | describe('numberString', () => { 8 | each([ 9 | ['25', 'Here is a 25 number'], 10 | ['-25', 'Here is a -25 number'], 11 | ['+25', 'Here is a +25 number'], 12 | ['25.00', 'Here is a 25.00 number'], 13 | ['2,500.00', 'Here is a 2,500.00 number'], 14 | ['1\u00A0000,99', 'Here is a 1\u00A0000,99 number'], 15 | ]).it('marks `%s` in `%s`', (mark, content) => { 16 | const Marker = createMarker([numberString]); 17 | const { container } = render({content}); 18 | const marks = container.querySelectorAll('mark'); 19 | expect(marks).toHaveLength(1); 20 | expect(marks[0].textContent).toEqual(mark); 21 | }); 22 | 23 | each([['3D game']]).it('does not mark anything in `%s`', content => { 24 | const Marker = createMarker([numberString]); 25 | const { container } = render({content}); 26 | const marks = container.querySelectorAll('mark'); 27 | expect(marks).toHaveLength(0); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/parsers/numberString.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks numbers. 5 | * 6 | * Example matches: 7 | * 8 | * 42 9 | * 42.0 10 | * 4,200.00 11 | * 4 200.00 (with no-break space) 12 | * 13 | * Source: 14 | * https://github.com/translate/translate/blob/2.3.1/translate/storage/placeables/general.py#L72 15 | */ 16 | export const numberString = { 17 | rule: /([-+]?[0-9]+([\u00A0.,][0-9]+)*)\b/u, 18 | matchIndex: 0, 19 | tag: x => ( 20 | 21 | {x} 22 | 23 | ), 24 | } satisfies Parser; 25 | -------------------------------------------------------------------------------- /src/parsers/optionPattern.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { optionPattern } from './optionPattern'; 6 | 7 | describe('optionPattern', () => { 8 | each([ 9 | ['--help', 'Type --help for this help'], 10 | ['-S', 'Short -S ones also'], 11 | ]).it('marks `%s` in `%s`', (mark, content) => { 12 | const Marker = createMarker([optionPattern]); 13 | const { container } = render({content}); 14 | const marks = container.querySelectorAll('mark'); 15 | expect(marks).toHaveLength(1); 16 | expect(marks[0].textContent).toEqual(mark); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/parsers/optionPattern.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks command line options. 5 | * 6 | * Example matches: 7 | * 8 | * --help 9 | * -i 10 | * 11 | * Source: 12 | * https://github.com/translate/translate/blob/2.3.1/translate/storage/placeables/general.py#L317 13 | */ 14 | export const optionPattern = { 15 | rule: /(\B(-[a-zA-Z]|--[a-z-]+)\b)/, 16 | matchIndex: 0, 17 | tag: x => ( 18 | 19 | {x} 20 | 21 | ), 22 | } satisfies Parser; 23 | -------------------------------------------------------------------------------- /src/parsers/punctuation.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { punctuation } from './punctuation'; 6 | 7 | describe('punctuation', () => { 8 | each([ 9 | ['™', 'Pontoon™'], 10 | ['℉', '9℉ OMG so cold'], 11 | ['π', 'She had π cats'], 12 | ['ʼ', 'Please use the correct quote: ʼ'], 13 | ['«', 'Here comes the French: «'], 14 | ['€', 'Gimme the €'], 15 | ['…', 'Downloading…'], 16 | ['—', 'Hello — Lisa'], 17 | ['–', 'Hello – Lisa'], 18 | [' ', 'Hello\u202Fworld'], 19 | ]).it('marks `%s` in `%s`', (mark, content) => { 20 | const Marker = createMarker([punctuation]); 21 | const { container } = render({content}); 22 | const marks = container.querySelectorAll('mark'); 23 | expect(marks).toHaveLength(1); 24 | expect(marks[0].textContent).toEqual(mark); 25 | }); 26 | 27 | each([['These, are not. Special: punctuation; marks! Or are "they"?']]).it( 28 | 'does not mark anything in `%s`', 29 | content => { 30 | const Marker = createMarker([punctuation]); 31 | const { container } = render({content}); 32 | const marks = container.querySelectorAll('mark'); 33 | expect(marks).toHaveLength(0); 34 | } 35 | ); 36 | }); 37 | -------------------------------------------------------------------------------- /src/parsers/punctuation.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks individual punctuation characters. 5 | * 6 | * Source: 7 | * https://github.com/translate/translate/blob/2.3.1/translate/storage/placeables/general.py#L229 8 | */ 9 | export const punctuation = { 10 | rule: new RegExp( 11 | '(' + 12 | '(' + 13 | /[™©®]|/.source + // Marks 14 | /[℃℉°]|/.source + // Degree related 15 | /[±πθ×÷−√∞∆Σ′″]|/.source + // Maths 16 | /[‘’ʼ‚‛“”„‟]|/.source + // Quote characters 17 | /[«»]|/.source + // Guillemets 18 | /[£¥€]|/.source + // Currencies 19 | /…|/.source + // U2026 - horizontal ellipsis 20 | /—|/.source + // U2014 - em dash 21 | /–|/.source + // U2013 - en dash 22 | /[\u202F]/.source + // U202F - narrow no-break space 23 | ')+' + 24 | ')' 25 | ), 26 | matchIndex: 0, 27 | tag: x => ( 28 | 29 | {x} 30 | 31 | ), 32 | } satisfies Parser; 33 | -------------------------------------------------------------------------------- /src/parsers/pythonFormatNamedString.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { pythonFormatNamedString } from './pythonFormatNamedString'; 6 | 7 | describe('pythonFormatNamedString', () => { 8 | each([ 9 | ['%(name)s', 'Hello %(name)s'], 10 | ['%(number)d', 'Rolling %(number)d dices'], 11 | ['%(name)S', 'Hello %(name)S'], 12 | ['%(number)D', 'Rolling %(number)D dices'], 13 | ]).it('marks `%s` in `%s`', (mark, content) => { 14 | const Marker = createMarker([pythonFormatNamedString]); 15 | const { container } = render({content}); 16 | const marks = container.querySelectorAll('mark'); 17 | expect(marks).toHaveLength(1); 18 | expect(marks[0].textContent).toEqual(mark); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/parsers/pythonFormatNamedString.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks Python formatting named variables. 5 | * 6 | * Example matches: 7 | * 8 | * %(name)s 9 | * %(number)D 10 | */ 11 | export const pythonFormatNamedString = { 12 | rule: /(%\([[\w\d!.,[\]%:$<>+\-= ]*\)[+|-|0\d+|#]?[.\d+]?[s|d|e|f|g|o|x|c|%])/i, 13 | tag: x => ( 14 | 15 | {x} 16 | 17 | ), 18 | } satisfies Parser; 19 | -------------------------------------------------------------------------------- /src/parsers/pythonFormatString.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { pythonFormatString } from './pythonFormatString'; 6 | 7 | describe('pythonFormatString', () => { 8 | each([ 9 | ['{0}', 'hello, {0}'], 10 | ['{name}', 'hello, {name}'], 11 | ['{name!s}', 'hello, {name!s}'], 12 | ['{someone.name}', 'hello, {someone.name}'], 13 | ['{name[0]}', 'hello, {name[0]}'], 14 | ]).it('marks `%s` in `%s`', (mark, content) => { 15 | const Marker = createMarker([pythonFormatString]); 16 | const { container } = render({content}); 17 | const marks = container.querySelectorAll('mark'); 18 | expect(marks).toHaveLength(1); 19 | expect(marks[0].textContent).toEqual(mark); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/parsers/pythonFormatString.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks Python new string formatting variables. 5 | * 6 | * Documentation: 7 | * https://docs.python.org/3/library/string.html#formatstrings 8 | * 9 | * Example matches: 10 | * 11 | * {0} 12 | * {number} 13 | * {foo[42]} 14 | */ 15 | export const pythonFormatString = { 16 | rule: /(\{{?[\w\d!.,[\]%:$<>+-= ]*\}?})/, 17 | tag: x => ( 18 | 19 | {x} 20 | 21 | ), 22 | } satisfies Parser; 23 | -------------------------------------------------------------------------------- /src/parsers/pythonFormattingVariable.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { pythonFormattingVariable } from './pythonFormattingVariable'; 6 | 7 | describe('pythonFormattingVariable', () => { 8 | each([ 9 | ['%%', '100%% correct'], 10 | ['%s', 'There were %s'], 11 | ['%(number)d', 'There were %(number)d cows'], 12 | ['%(cows.number)d', 'There were %(cows.number)d cows'], 13 | ['%(number of cows)d', 'There were %(number of cows)d cows'], 14 | ['%(number)03d', 'There were %(number)03d cows'], 15 | ['%(number)*d', 'There were %(number)*d cows'], 16 | ['%(number)3.1d', 'There were %(number)3.1d cows'], 17 | ['%(number)Ld', 'There were %(number)Ld cows'], 18 | ['%s', 'path/to/file_%s.png'], 19 | ['%s', 'path/to/%sfile.png'], 20 | ]).it('marks `%s` in `%s`', (mark, content) => { 21 | const Marker = createMarker([pythonFormattingVariable]); 22 | const { container } = render({content}); 23 | const marks = container.querySelectorAll('mark'); 24 | expect(marks).toHaveLength(1); 25 | expect(marks[0].textContent).toEqual(mark); 26 | }); 27 | 28 | each([ 29 | ['10 % complete'], 30 | // We used to match '%(number) 3d' here, but don't anymore to avoid 31 | // false positives. 32 | // See https://bugzilla.mozilla.org/show_bug.cgi?id=1251186 33 | ['There were %(number) 3d cows'], 34 | ]).it('does not mark anything in `%s`', content => { 35 | const Marker = createMarker([pythonFormattingVariable]); 36 | const { container } = render({content}); 37 | const marks = container.querySelectorAll('mark'); 38 | expect(marks).toHaveLength(0); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/parsers/pythonFormattingVariable.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks Python string formatting variables. 5 | * 6 | * Implemented following Python documentation on String Formatting Operations: 7 | * https://docs.python.org/2/library/stdtypes.html#string-formatting 8 | * 9 | * Example matches: 10 | * 11 | * %s 12 | * %(tag)d 13 | * %(number)3.1d 14 | * 15 | * Source: 16 | * https://github.com/translate/translate/blob/2.3.1/translate/storage/placeables/general.py#L115 17 | */ 18 | export const pythonFormattingVariable = { 19 | rule: /(%(%|(\([^)]+\)){0,1}[-+0#]{0,1}(\d+|\*){0,1}(\.(\d+|\*)){0,1}[hlL]{0,1}[diouxXeEfFgGcrs]{1}))/, 20 | matchIndex: 0, 21 | tag: x => ( 22 | 26 | {x} 27 | 28 | ), 29 | } satisfies Parser; 30 | -------------------------------------------------------------------------------- /src/parsers/qtFormatting.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { qtFormatting } from './qtFormatting'; 6 | 7 | describe('qtFormatting', () => { 8 | each([ 9 | ['%1', 'Hello, %1'], 10 | ['%99', 'Hello, %99'], 11 | ['%L1', 'Hello, %L1'], 12 | ]).it('marks `%s` in `%s`', (mark, content) => { 13 | const Marker = createMarker([qtFormatting]); 14 | const { container } = render({content}); 15 | const marks = container.querySelectorAll('mark'); 16 | expect(marks).toHaveLength(1); 17 | expect(marks[0].textContent).toEqual(mark); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/parsers/qtFormatting.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks Qt string formatting variables. 5 | * 6 | * Implemented following Qt documentation on QString::arg: 7 | * https://doc.qt.io/qt-5/qstring.html#arg 8 | * The placeables are refered to as 'place markers'. 9 | * 10 | * Notes: 11 | * - Place markers can be reordered 12 | * - Place markers may be repeated 13 | * - 'L' use a localised representation e.g. in a number 14 | * - %% some in the wild to escape real %, not documented (not in regex) 15 | * 16 | * Example matches: 17 | * 18 | * %1 19 | * %99 20 | * %L1 21 | * 22 | * Source: 23 | * https://github.com/translate/translate/blob/2.3.1/translate/storage/placeables/general.py#L80 24 | */ 25 | export const qtFormatting = { 26 | rule: /(%L?[1-9]\d{0,1}(?=([^\d]|$)))/, 27 | matchIndex: 0, 28 | tag: x => ( 29 | 30 | {x} 31 | 32 | ), 33 | } satisfies Parser; 34 | -------------------------------------------------------------------------------- /src/parsers/shortCapitalNumberString.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { shortCapitalNumberString } from './shortCapitalNumberString'; 6 | 7 | describe('shortCapitalNumberString', () => { 8 | each([ 9 | ['3D', '3D'], 10 | ['A4', 'Use the A4 paper'], 11 | ]).it('marks `%s` in `%s`', (mark, content) => { 12 | const Marker = createMarker([shortCapitalNumberString]); 13 | const { container } = render({content}); 14 | const marks = container.querySelectorAll('mark'); 15 | expect(marks).toHaveLength(1); 16 | expect(marks[0].textContent).toEqual(mark); 17 | }); 18 | 19 | each([['I am'], ['3d'], ['3DS']]).it( 20 | 'does not mark anything in `%s`', 21 | content => { 22 | const Marker = createMarker([shortCapitalNumberString]); 23 | const { container } = render({content}); 24 | const marks = container.querySelectorAll('mark'); 25 | expect(marks).toHaveLength(0); 26 | } 27 | ); 28 | }); 29 | -------------------------------------------------------------------------------- /src/parsers/shortCapitalNumberString.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks 2-letters-long terms containing a combination of a capital letter and 5 | * a number. 6 | * 7 | * Example matches: 8 | * 9 | * 3D 10 | * A4 11 | */ 12 | export const shortCapitalNumberString = { 13 | rule: /(\b([A-Z][0-9])|([0-9][A-Z])\b)/, 14 | tag: x => ( 15 | 19 | {x} 20 | 21 | ), 22 | } satisfies Parser; 23 | -------------------------------------------------------------------------------- /src/parsers/stringFormattingVariable.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { stringFormattingVariable } from './stringFormattingVariable'; 6 | 7 | describe('stringFormattingVariable', () => { 8 | each([ 9 | ['%d', 'There were %d cows', 1], 10 | ['%Id', 'There were %Id cows', 1], 11 | [['%d', '%s'], 'There were %d %s', 2], 12 | [['%1$s', '%2$s'], '%1$s was kicked by %2$s', 2], 13 | ['%Id', 'There were %Id cows', 1], 14 | ["%'f", "There were %'f cows", 1], 15 | ['%#x', 'There were %#x cows', 1], 16 | ['%3d', 'There were %3d cows', 1], 17 | ['%33d', 'There were %33d cows', 1], 18 | ['%*d', 'There were %*d cows', 1], 19 | ['%1$d', 'There were %1$d cows', 1], 20 | [null, 'There were %\u00a0d cows', 0], 21 | ]).it('marks `%s` in `%s`', (mark, content, matchesNumber) => { 22 | const Marker = createMarker([stringFormattingVariable]); 23 | const { container } = render({content}); 24 | const marks = container.querySelectorAll('mark'); 25 | expect(marks).toHaveLength(matchesNumber); 26 | if (matchesNumber === 1) { 27 | expect(marks[0].textContent).toEqual(mark); 28 | } else if (matchesNumber > 1) { 29 | for (let i = 0; i < matchesNumber; i++) { 30 | expect(marks[i].textContent).toEqual(mark[i]); 31 | } 32 | } 33 | }); 34 | 35 | each([ 36 | ['10 % complete'], 37 | // We used to match '% d' here, but don't anymore to avoid 38 | // false positives. 39 | // See https://bugzilla.mozilla.org/show_bug.cgi?id=1251186 40 | ['There were % d cows'], 41 | ]).it('does not mark anything in `%s`', content => { 42 | const Marker = createMarker([stringFormattingVariable]); 43 | const { container } = render({content}); 44 | const marks = container.querySelectorAll('mark'); 45 | expect(marks).toHaveLength(0); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/parsers/stringFormattingVariable.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks printf string formatting variables. 5 | * 6 | * See `man 3 printf` for documentation. Not everything is supported. 7 | * 8 | * Example matches: 9 | * 10 | * %d 11 | * %Id 12 | * %33d 13 | * 14 | * Source: 15 | * https://github.com/translate/translate/blob/2.3.1/translate/storage/placeables/general.py#L154 16 | */ 17 | export const stringFormattingVariable = { 18 | rule: /(%(\d+\$)?[-+0#'I]?((\d+)|[*])?(\.\d+)?[hlI]?[cCdiouxXeEfgGnpsS])/, 19 | matchIndex: 0, 20 | tag: x => ( 21 | 25 | {x} 26 | 27 | ), 28 | } satisfies Parser; 29 | -------------------------------------------------------------------------------- /src/parsers/tabCharacter.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { createMarker } from '../createMarker'; 3 | 4 | import { tabCharacter } from './tabCharacter'; 5 | 6 | describe('tabCharacter', () => { 7 | it('marks the right parts of a string', () => { 8 | const Marker = createMarker([tabCharacter]); 9 | const content = 'hello,\tworld'; 10 | 11 | const { container } = render({content}); 12 | const marks = container.querySelectorAll('mark'); 13 | expect(marks).toHaveLength(1); 14 | expect(marks[0].textContent).toEqual('\u2192'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/parsers/tabCharacter.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks the tab character "\t". 5 | */ 6 | export const tabCharacter = { 7 | rule: '\t', 8 | tag: x => ( 9 | 10 | 11 | 12 | ), 13 | } satisfies Parser; 14 | -------------------------------------------------------------------------------- /src/parsers/thinSpace.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { createMarker } from '../createMarker'; 3 | 4 | import { thinSpace } from './thinSpace'; 5 | 6 | describe('thinSpace', () => { 7 | it('marks the right parts of a string', () => { 8 | const Marker = createMarker([thinSpace]); 9 | const content = 'hello,\u2009world'; 10 | 11 | const { container } = render({content}); 12 | const marks = container.querySelectorAll('mark'); 13 | expect(marks).toHaveLength(1); 14 | expect(marks[0].textContent).toEqual('\u2009'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/parsers/thinSpace.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks the thin space character (Unicode U+2009). 5 | */ 6 | export const thinSpace = { 7 | rule: /([\u2009])/, 8 | tag: x => ( 9 | 10 | {x} 11 | 12 | ), 13 | } satisfies Parser; 14 | -------------------------------------------------------------------------------- /src/parsers/unusualSpace.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { unusualSpace } from './unusualSpace'; 6 | 7 | describe('unusualSpace', () => { 8 | each([ 9 | [' ', 'hello world '], 10 | [' ', 'hello\n world'], 11 | [' ', 'hello world'], 12 | ]).it('marks `%s` in `%s`', (mark, content) => { 13 | const Marker = createMarker([unusualSpace]); 14 | const { container } = render({content}); 15 | const marks = container.querySelectorAll('mark'); 16 | expect(marks).toHaveLength(1); 17 | expect(marks[0].textContent).toEqual(mark); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/parsers/unusualSpace.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks unusually placed spaces: 5 | * - at the end of a line 6 | * - after a newline or tab 7 | * - multiple spaces 8 | * 9 | * Example matches: 10 | * 11 | * "hello world " 12 | * "hello\t world" 13 | * "hello world" 14 | */ 15 | export const unusualSpace = { 16 | rule: /( +$|[\r\n\t]( +)| {2,})/, 17 | tag: x => ( 18 | 19 | {x} 20 | 21 | ), 22 | } satisfies Parser; 23 | -------------------------------------------------------------------------------- /src/parsers/uriPattern.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { uriPattern } from './uriPattern'; 6 | 7 | describe('uriPattern', () => { 8 | each([ 9 | ['http://example.org/'], 10 | ['https://example.org/'], 11 | ['ftp://example.org/'], 12 | ['nttp://example.org/'], 13 | ['file://example.org/'], 14 | ['irc://example.org/'], 15 | ['www.example.org/'], 16 | ['ftp.example.org/'], 17 | ['http://example.org:8888'], 18 | ['http://example.org:8888/?'], 19 | ['http://example.org/path/to/resource?var1=$@3!?%=iwdu8'], 20 | ['http://example.org/path/to/resource?var1=$@3!?%=iwdu8&var2=bar'], 21 | ['HTTP://EXAMPLE.org/'], 22 | ]).it('correctly marks URI `%s`', uri => { 23 | const Marker = createMarker([uriPattern]); 24 | const { container } = render({uri}); 25 | const marks = container.querySelectorAll('mark'); 26 | expect(marks).toHaveLength(1); 27 | expect(marks[0].textContent).toEqual(uri); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/parsers/uriPattern.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks terms that look like a URI. 5 | * 6 | * Example matches: 7 | * 8 | * https://example.org 9 | * www.example.org/resource/42 10 | * ftp://example.org/ 11 | * 12 | * Source: 13 | * https://github.com/translate/translate/blob/2.3.1/translate/storage/placeables/general.py#L192 14 | */ 15 | export const uriPattern = { 16 | rule: new RegExp( 17 | '(' + 18 | '(' + 19 | '(' + 20 | /((news|nttp|file|https?|ftp|irc):\/\/)/.source + // has to start with a protocol 21 | /|((www|ftp)[-A-Za-z0-9]*\.)/.source + // or www... or ftp... hostname 22 | ')' + 23 | /([-A-Za-z0-9]+(\.[-A-Za-z0-9]+)*)/.source + // hostname 24 | /|(\d{1,3}(\.\d{1,3}){3,3})/.source + // or IP address 25 | ')' + 26 | /(:[0-9]{1,5})?/.source + // optional port 27 | /(\/[a-zA-Z0-9-_$.+!*(),;:@&=?/~#%]*)?/.source + // optional trailing path 28 | /(?=$|\s|([\]'}>),"]))/.source + 29 | ')', 30 | 'i' // This one is not case sensitive. 31 | ), 32 | matchIndex: 0, 33 | tag: x => ( 34 | 35 | {x} 36 | 37 | ), 38 | } satisfies Parser; 39 | -------------------------------------------------------------------------------- /src/parsers/xmlEntity.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { xmlEntity } from './xmlEntity'; 6 | 7 | describe('xmlEntity', () => { 8 | each([ 9 | ['&brandShortName;', 'Welcome to &brandShortName;'], 10 | ['Ӓ', 'hello, Ӓ'], 11 | ['&xDEAD;', 'hello, &xDEAD;'], 12 | ]).it('marks `%s` in `%s`', (mark, content) => { 13 | const Marker = createMarker([xmlEntity]); 14 | const { container } = render({content}); 15 | const marks = container.querySelectorAll('mark'); 16 | expect(marks).toHaveLength(1); 17 | expect(marks[0].textContent).toEqual(mark); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/parsers/xmlEntity.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks XML entities. 5 | * 6 | * Example matches: 7 | * 8 | * &brandShortName; 9 | * Ӓ 10 | * 11 | * Source: 12 | * https://github.com/translate/translate/blob/2.3.1/translate/storage/placeables/general.py#L254 13 | */ 14 | export const xmlEntity = { 15 | rule: /(&(([a-zA-Z][a-zA-Z0-9.-]*)|([#](\d{1,5}|x[a-fA-F0-9]{1,5})+));)/, 16 | matchIndex: 0, 17 | tag: x => ( 18 | 19 | {x} 20 | 21 | ), 22 | } satisfies Parser; 23 | -------------------------------------------------------------------------------- /src/parsers/xmlTag.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import each from 'jest-each'; 3 | import { createMarker } from '../createMarker'; 4 | 5 | import { xmlTag } from './xmlTag'; 6 | 7 | describe('xmlTag', () => { 8 | each([ 9 | ['', 'hello, John'], 10 | ['', 'hello, '], 11 | ['', 'hello, '], 12 | ["", "hello, "], 13 | ["", "hello, "], 14 | ['', 'Happy !'], 15 | ]).it('marks `%s` in `%s`', (mark, content) => { 16 | const Marker = createMarker([xmlTag]); 17 | const { container } = render({content}); 18 | const marks = container.querySelectorAll('mark'); 19 | expect(marks).toHaveLength(1); 20 | expect(marks[0].textContent).toEqual(mark); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/parsers/xmlTag.tsx: -------------------------------------------------------------------------------- 1 | import type { Parser } from '../index'; 2 | 3 | /** 4 | * Marks XML tags. 5 | * 6 | * Example matches: 7 | * 8 | * 9 | * 10 | * 11 | * 12 | * Source: 13 | * https://github.com/translate/translate/blob/2.3.1/translate/storage/placeables/general.py#L301 14 | */ 15 | export const xmlTag = { 16 | rule: /(<[\w.:]+(\s([\w.:-]+=((".*?")|('.*?')))?)*\/?>|<\/[\w.]+>)/, 17 | matchIndex: 0, 18 | tag: x => ( 19 | 20 | {x} 21 | 22 | ), 23 | } satisfies Parser; 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**/*.ts", 4 | "src/**/*.tsx" 5 | ], 6 | "compilerOptions": { 7 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 8 | 9 | /* Basic Options */ 10 | // "incremental": true, /* Enable incremental compilation */ 11 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 12 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 13 | // "lib": [], /* Specify library files to be included in the compilation. */ 14 | // "allowJs": true, /* Allow javascript files to be compiled. */ 15 | // "checkJs": true, /* Report errors in .js files. */ 16 | "jsx": "react-jsx", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 17 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 18 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 19 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 20 | // "outFile": "./", /* Concatenate and emit output to single file. */ 21 | "outDir": "./lib", /* Redirect output structure to the directory. */ 22 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 23 | // "composite": true, /* Enable project compilation */ 24 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 25 | // "removeComments": true, /* Do not emit comments to output. */ 26 | // "noEmit": true, /* Do not emit outputs. */ 27 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 28 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 29 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 30 | 31 | /* Strict Type-Checking Options */ 32 | "strict": true, /* Enable all strict type-checking options. */ 33 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 34 | // "strictNullChecks": true, /* Enable strict null checks. */ 35 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 36 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 37 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 38 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 39 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 40 | 41 | /* Additional Checks */ 42 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 43 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 44 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 45 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 46 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 47 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 48 | 49 | /* Module Resolution Options */ 50 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 51 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 52 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 53 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 54 | // "typeRoots": [], /* List of folders to include type definitions from. */ 55 | // "types": [], /* Type declaration files to be included in compilation. */ 56 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 57 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 58 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 59 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 60 | 61 | /* Source Map Options */ 62 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 63 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 64 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 65 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 66 | 67 | /* Experimental Options */ 68 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 69 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 70 | 71 | /* Advanced Options */ 72 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 73 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 74 | } 75 | } 76 | --------------------------------------------------------------------------------