├── .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 |
--------------------------------------------------------------------------------