mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
* Remove double spaces * Enforce curly apostrophes * Enforce French typographic spaces in fr.json Add vitest test enforcing French spacing rules per https://typographisme.net/post/Les-espaces-typographiques-et-le-web: - NNBSP (U+202F) before ? ! ; - NBSP (U+00A0) before : - NBSP (U+00A0) around guillemets « » Fix 38 fr.json values that were using NBSP (or no space) before ? ! ;. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Extend French typography test to numbers and percent signs Add rules per https://typographisme.net/post/Les-espaces-typographiques-et-le-web: - NNBSP (U+202F) between digit groups (thousands separator) - NBSP (U+00A0) between a number and a following word/unit - NBSP (U+00A0) before % Fix 9 fr.json values where a regular space was used instead of NBSP between a number and a following word (e.g. "1 résultat", "10 jetons", "7 jours"). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Use point médian for inclusive writing in fr.json Replace parenthetical gender/plural markers like "sûr(e)", "abonné(e)", "licence(s)" with the point médian "·" (e.g. "sûr·e", "abonné·e", "licence·s") per modern French écriture inclusive. Add a vitest rule rejecting "word(suffix)" patterns where suffix is 1-3 letters and there is no space before the opening parenthesis. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Simplify inclusive-writing check to "(e)" substring match The previous regex (matchAll without the /g flag) would have thrown at runtime. Drop matchAll entirely and use includes('(e)'). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Add point médian to participles in fr.json Apply inclusive writing (·e) to past participles agreeing with the addressed user ("affilié·e", "invité·e", "connecté·e", "abonné·e", etc.). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Use inclusive forms in fr.json * Change "de Overleaf" to "d’Overleaf" * Fix tests * Check for consecutive spaces, not just double spaces * Update comment about guillemets * Add ESLint rules for locale JSON conventions Add three rules to @overleaf/eslint-plugin that run against locale JSON files via @eslint/json: - no-consecutive-spaces-in-locales - no-straight-apostrophes-in-locales - french-typography-in-locales Wire them up in services/web/eslint.config.mjs against `locales/*.json` (and `locales/fr.json` for the French-typography rule). * Add `@eslint/json` so JSON files can be parsed * Scope JS/TS-specific config to JS/TS files only so that built-in rules aren't applied to JSON files * Disable `no-irregular-whitespace` in french-typography-in-locales.js * Make the rules fixable * Improve regexes: user `\s` instead of custom matchers * Add sorted-keys-in-locales ESLint rule Mirrors scripts/translations/sort.js as a fixable ESLint rule: require top-level keys in locale JSON files to be sorted alphabetically. The autofix re-serializes the file with sorted keys. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Add no-unused-locale-keys ESLint rule Mirrors scripts/translations/cleanupUnusedLocales.js as a fixable ESLint rule applied only to en.json. Shells out to `find` + `cat` once per process (cached) to grep all candidate source files for key references, then reports any en.json key that isn't found. The autofix re-serializes en.json without the unused keys. Hardcoded ALWAYS_USED set covers keys built at runtime (month names, third-party-datastore notifications) that the regex scan can't see. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Add locale-variables-match-en ESLint rule Mirrors scripts/translations/checkVariables.js as an ESLint rule applied to non-en locale JSON files. Reads en.json (cached) and reports any member whose `__name__` placeholders differ from the base. GLOBALS (e.g. __appName__) are allowed as extras; IGNORE_NESTING_FOR preserves the script's per-key exceptions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Replace some double linebreak (@overleaf/no-consecutive-spaces-in-locales) * Add no-orphan-locale-keys ESLint rule Detect translation keys in non-en locale files that aren't present in en.json. Mirrors the `--sync-non-en` flag of cleanupUnusedLocales.js. The autofix re-serializes the locale file without the orphan keys, sorted alphabetically. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Add tests for the locale JSON ESLint rules Cover the four single-file rules: - no-consecutive-spaces-in-locales - no-straight-apostrophes-in-locales - sorted-keys-in-locales - french-typography-in-locales (including the inclusive-writing suggestion for "(e)") The three cross-file rules (no-unused-locale-keys, no-orphan-locale-keys, locale-variables-match-en) read from disk and aren't covered here; they need integration tests via fixtures. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Drop bin/lint_locales — checks are now covered by ESLint rules The four checks in bin/lint_locales are duplicated by the new @overleaf/eslint-plugin rules: - sort.js --check → sorted-keys-in-locales - cleanupUnusedLocales.js --check → no-unused-locale-keys + no-orphan-locale-keys - checkVariables.js → locale-variables-match-en - single-quote grep → no-straight-apostrophes-in-locales `yarn lint` runs the ESLint rules against locales/*.json, so the lint_locales script and Makefile target are redundant. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Remove Locales.test.mjs * Delete scripts replaced by @overleaf/eslint-plugin rules Remove the three locale scripts now superseded by ESLint rules: - scripts/translations/sort.js → sorted-keys-in-locales - scripts/translations/cleanupUnusedLocales.js → no-unused-locale-keys + no-orphan-locale-keys - scripts/translations/checkVariables.js → locale-variables-match-en Also drop the matching Makefile targets (sort_locales, cleanup_unused_locales), update AGENTS.md / copilot-instructions.md to point at `yarn lint --fix` instead, and prune the deleted entries from the esm-check-migration allowlist. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Delete redundant scripts/translations/sort-locale-file.js Single-file sort utility — superseded by `yarn lint --fix path/to/file.json` via the sorted-keys-in-locales ESLint rule. No callers in repo. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Update comments and messages * Add test file pattern to ESLint configuration * Reorder "Backend specific rules" so `no-extraneous-dependencies` doesn't fail on test files * Make french-typography fixes idempotent and catch edge cases Apply Copilot's review feedback on PR #34221: - autoFix() now uses `\\s*`/`\\s+` instead of `\\s?`/`\\s` so it collapses any run of mixed whitespace into the correct NBSP/NNBSP, making the fix idempotent and order-independent. - The `:` lint regex now captures optional whitespace and reports when it isn't NBSP (catches `"Liste :"` with a regular space). - The `%` lint regex now matches the missing-space case (`"10%"`) via `\\s*` instead of requiring one char between digit and `%`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Remove sort-locale-file.js from pre-commit * Revert no-unused-locale-keys ESLint rule; Restore lint_locales scripts --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> GitOrigin-RevId: 8351fab074e6e7f77920b70237df1cd1c781e053
457 lines
14 KiB
JavaScript
457 lines
14 KiB
JavaScript
const { RuleTester } = require('eslint')
|
||
const tsParser = require('@typescript-eslint/parser')
|
||
const json = require('@eslint/json').default
|
||
const noThrowInCallback = require('./no-throw-in-callback')
|
||
const preferKebabUrl = require('./prefer-kebab-url')
|
||
const noUnnecessaryTrans = require('./no-unnecessary-trans')
|
||
const shouldUnescapeTrans = require('./should-unescape-trans')
|
||
const noGeneratedEditorThemes = require('./no-generated-editor-themes')
|
||
const viDoMockValidPath = require('./require-vi-doMock-valid-path')
|
||
const requireCioSnakeCaseProperties = require('./require-cio-snake-case-properties')
|
||
const noConsecutiveSpacesInLocales = require('./no-consecutive-spaces-in-locales')
|
||
const noStraightApostrophesInLocales = require('./no-straight-apostrophes-in-locales')
|
||
const frenchTypographyInLocales = require('./french-typography-in-locales')
|
||
const sortedKeysInLocales = require('./sorted-keys-in-locales')
|
||
|
||
const ruleTester = new RuleTester({
|
||
languageOptions: {
|
||
parser: tsParser,
|
||
ecmaVersion: 'latest',
|
||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||
},
|
||
})
|
||
|
||
const jsonRuleTester = new RuleTester({
|
||
plugins: { json },
|
||
language: 'json/json',
|
||
})
|
||
|
||
ruleTester.run('prefer-kebab-url', preferKebabUrl, {
|
||
valid: [
|
||
{ code: `app.get('/foo-bar')` },
|
||
{ code: `app.get('/foo-bar/:id')` },
|
||
{ code: `router.post('/foo-bar')` },
|
||
{ code: `router.get('/foo-bar/:id/:name/:age')` },
|
||
{ code: `webRouter.get('/foo-bar/:user_id/(ProjectName)/get-info')` },
|
||
{ code: `webApp.post('/foo-bar/:user_id/(ProjectName)/get-info')` },
|
||
{
|
||
code: `router.get(/^\\/download\\/project\\/([^/]*)\\/output\\/output\\.pdf$/)`,
|
||
},
|
||
{
|
||
code: `webRouter.get(/^\\/project\\/([^/]*)\\/user\\/([0-9a-f]+)\\/build\\/([0-9a-f-]+)\\/output\\/(.*)$/)`,
|
||
},
|
||
],
|
||
invalid: [
|
||
{
|
||
code: `app.get('/fooBar')`,
|
||
errors: [
|
||
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
|
||
],
|
||
},
|
||
{
|
||
code: `app.get('/fooBar/:id')`,
|
||
errors: [
|
||
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
|
||
],
|
||
},
|
||
{
|
||
code: `webRouter.get('/foo_bar/:id/FooBar/:name/fooBar')`,
|
||
errors: [
|
||
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
|
||
],
|
||
},
|
||
{
|
||
code: `router.get(/^\\/downLoad\\/pro-ject\\/([^/]*)\\/OutPut\\/out-put\\.pdf$/)`,
|
||
errors: [
|
||
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
|
||
],
|
||
},
|
||
],
|
||
})
|
||
|
||
ruleTester.run('no-unnecessary-trans', noUnnecessaryTrans, {
|
||
valid: [
|
||
{ code: `<Trans i18nKey="test" components={{ strong: <strong/> }}/>` },
|
||
],
|
||
invalid: [
|
||
{
|
||
code: `<Trans i18nKey="test" values={{ test: 'foo '}}/>`,
|
||
errors: [{ message: `Use t('…') when there are no components` }],
|
||
},
|
||
{
|
||
code: `<Trans i18nKey="test" />`,
|
||
errors: [{ message: `Use t('…') when there are no components` }],
|
||
output: `{t('test')}`,
|
||
},
|
||
],
|
||
})
|
||
|
||
ruleTester.run('should-unescape-trans', shouldUnescapeTrans, {
|
||
valid: [
|
||
{
|
||
code: `<Trans i18nKey="test" components={{ strong: <strong/> }}/>`,
|
||
},
|
||
{
|
||
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape tOptions={{ interpolation: { escapeValue: true } }}/>`,
|
||
},
|
||
],
|
||
invalid: [
|
||
{
|
||
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} />`,
|
||
errors: [{ message: 'Trans with values must have shouldUnescape' }],
|
||
output: `<Trans i18nKey="test" values={{ foo: 'bar' }}\nshouldUnescape components={{ strong: <strong/> }} />`,
|
||
},
|
||
{
|
||
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape />`,
|
||
errors: [
|
||
{
|
||
message:
|
||
'Trans with shouldUnescape must have tOptions.interpolation.escapeValue',
|
||
},
|
||
],
|
||
output: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape\ntOptions={{ interpolation: { escapeValue: true } }} />`,
|
||
},
|
||
],
|
||
})
|
||
|
||
const noGeneratedEditorThemesError =
|
||
'EditorView.theme and EditorView.baseTheme each add CSS to the page for every instance of the theme. Store the theme in a variable and reuse it instead.'
|
||
ruleTester.run('no-generated-editor-themes', noGeneratedEditorThemes, {
|
||
valid: [
|
||
{
|
||
code: `EditorView.theme({ '.cm-editor': { color: 'black' } })`,
|
||
},
|
||
{
|
||
code: `const theme = EditorView.theme({ '.cm-editor': { color: 'black' } })`,
|
||
},
|
||
],
|
||
invalid: [
|
||
{
|
||
code: `function createTheme() { return EditorView.theme({ '.cm-editor': { color: 'black' } }) }`,
|
||
errors: [
|
||
{
|
||
message: noGeneratedEditorThemesError,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
code: `() => EditorView.theme({ '.cm-editor': { color: 'black' } })`,
|
||
errors: [
|
||
{
|
||
message: noGeneratedEditorThemesError,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
code: `class Foo { createTheme() { return EditorView.theme({ '.cm-editor': { color: 'black' } }) } }`,
|
||
errors: [
|
||
{
|
||
message: noGeneratedEditorThemesError,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
})
|
||
|
||
ruleTester.run('domock-require-valid-path', viDoMockValidPath, {
|
||
valid: [
|
||
{
|
||
code: 'vi.doMock("./require-vi-doMock-valid-path.js")',
|
||
filename: __filename,
|
||
},
|
||
{
|
||
code: 'const filename = "./require-vi-doMock-valid-path.js"; vi.doMock(filename);',
|
||
filename: __filename,
|
||
},
|
||
],
|
||
invalid: [
|
||
{
|
||
code: "vi.doMock('./require-vi-doMock-valid-path2')",
|
||
filename: __filename,
|
||
errors: [
|
||
{
|
||
message:
|
||
'The path "./require-vi-doMock-valid-path2" in vi.doMock() cannot be resolved relative to the current file.',
|
||
suggestions: [],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
code: 'const filename = "./require-vi-doMock-valid-path2.js"; vi.doMock(filename);',
|
||
filename: __filename,
|
||
errors: [
|
||
{
|
||
message:
|
||
'The first argument of vi.doMock() must be (or resolve to) a string literal representing a path.',
|
||
suggestions: [],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
})
|
||
|
||
ruleTester.run(
|
||
'require-cio-snake-case-properties',
|
||
requireCioSnakeCaseProperties,
|
||
{
|
||
valid: [
|
||
// updateUserAttributes with snake_case keys
|
||
{
|
||
code: `CustomerIoHandler.updateUserAttributes(userId, { plan_type: 'free', group_size: 10 })`,
|
||
},
|
||
// Modules.promises.hooks.fire with snake_case keys
|
||
{
|
||
code: `Modules.promises.hooks.fire('setUserProperties', userId, { plan_type: 'free', last_active: 123 })`,
|
||
},
|
||
// Modules.hooks.fire with snake_case keys
|
||
{
|
||
code: `Modules.hooks.fire('setUserProperties', userId, { plan_type: 'free' })`,
|
||
},
|
||
// Single-word keys are valid snake_case
|
||
{
|
||
code: `CustomerIoHandler.updateUserAttributes(userId, { email: 'a@b.com', role: 'admin' })`,
|
||
},
|
||
// Computed/dynamic keys are skipped
|
||
{
|
||
code: `CustomerIoHandler.updateUserAttributes(userId, { [dynamicKey]: true })`,
|
||
},
|
||
// Spread elements are skipped
|
||
{
|
||
code: `CustomerIoHandler.updateUserAttributes(userId, { ...existingAttrs })`,
|
||
},
|
||
// Unrelated function calls are not checked
|
||
{
|
||
code: `SomeOtherHandler.updateUserAttributes(userId, { camelCase: true })`,
|
||
},
|
||
// fire() with a different event name is not checked
|
||
{
|
||
code: `Modules.promises.hooks.fire('someOtherEvent', userId, { camelCase: true })`,
|
||
},
|
||
],
|
||
invalid: [
|
||
// camelCase key in updateUserAttributes
|
||
{
|
||
code: `CustomerIoHandler.updateUserAttributes(userId, { planType: 'free' })`,
|
||
errors: [
|
||
{
|
||
message: `Customer.io attribute 'planType' must be in snake_case.`,
|
||
},
|
||
],
|
||
},
|
||
// kebab-case string key
|
||
{
|
||
code: `CustomerIoHandler.updateUserAttributes(userId, { 'plan-type': 'free' })`,
|
||
errors: [
|
||
{
|
||
message: `Customer.io attribute 'plan-type' must be in snake_case.`,
|
||
},
|
||
],
|
||
},
|
||
// PascalCase key
|
||
{
|
||
code: `CustomerIoHandler.updateUserAttributes(userId, { PlanType: 'free' })`,
|
||
errors: [
|
||
{
|
||
message: `Customer.io attribute 'PlanType' must be in snake_case.`,
|
||
},
|
||
],
|
||
},
|
||
// camelCase in Modules.promises.hooks.fire
|
||
{
|
||
code: `Modules.promises.hooks.fire('setUserProperties', userId, { planType: 'free' })`,
|
||
errors: [
|
||
{
|
||
message: `Customer.io attribute 'planType' must be in snake_case.`,
|
||
},
|
||
],
|
||
},
|
||
// camelCase in Modules.hooks.fire
|
||
{
|
||
code: `Modules.hooks.fire('setUserProperties', userId, { planType: 'free' })`,
|
||
errors: [
|
||
{
|
||
message: `Customer.io attribute 'planType' must be in snake_case.`,
|
||
},
|
||
],
|
||
},
|
||
// Multiple invalid keys report multiple errors
|
||
{
|
||
code: `CustomerIoHandler.updateUserAttributes(userId, { planType: 'free', groupSize: 10, plan_term: 'annual' })`,
|
||
errors: [
|
||
{
|
||
message: `Customer.io attribute 'planType' must be in snake_case.`,
|
||
},
|
||
{
|
||
message: `Customer.io attribute 'groupSize' must be in snake_case.`,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
}
|
||
)
|
||
|
||
const noThrowInCallbackMessage =
|
||
'Pass the error to the callback instead of throwing in callback-based code.'
|
||
ruleTester.run('no-throw-in-callback', noThrowInCallback, {
|
||
valid: [
|
||
// Calling the callback with an error is fine
|
||
{ code: `function foo(cb) { cb(new Error()) }` },
|
||
// async functions may throw (they return a rejected promise)
|
||
{ code: `async function foo(cb) { throw new Error() }` },
|
||
// Last param not a callback name — not a callback-style function
|
||
{ code: `function foo(data) { throw new Error() }` },
|
||
// No params at all
|
||
{ code: `function foo() { throw new Error() }` },
|
||
// throw inside a nested non-callback function is fine
|
||
{ code: `function foo(cb) { [1].map(function() { throw new Error() }) }` },
|
||
// throw inside a nested async arrow is fine
|
||
{ code: `function foo(cb) { [1].map(async () => { throw new Error() }) }` },
|
||
],
|
||
invalid: [
|
||
{
|
||
code: `function foo(cb) { throw new Error() }`,
|
||
errors: [{ message: noThrowInCallbackMessage }],
|
||
},
|
||
{
|
||
code: `function foo(callback) { throw new Error() }`,
|
||
errors: [{ message: noThrowInCallbackMessage }],
|
||
},
|
||
{
|
||
code: `function foo(done) { throw new Error() }`,
|
||
errors: [{ message: noThrowInCallbackMessage }],
|
||
},
|
||
{
|
||
code: `function foo(next) { throw new Error() }`,
|
||
errors: [{ message: noThrowInCallbackMessage }],
|
||
},
|
||
{
|
||
code: `function foo(data, cb) { throw new Error() }`,
|
||
errors: [{ message: noThrowInCallbackMessage }],
|
||
},
|
||
{
|
||
code: `const foo = (cb) => { throw new Error() }`,
|
||
errors: [{ message: noThrowInCallbackMessage }],
|
||
},
|
||
// throw in a nested callback-style function inside another callback function
|
||
{
|
||
code: `function foo(cb) { bar(function(done) { throw new Error() }) }`,
|
||
errors: [{ message: noThrowInCallbackMessage }],
|
||
},
|
||
],
|
||
})
|
||
|
||
|
||
jsonRuleTester.run(
|
||
'no-consecutive-spaces-in-locales',
|
||
noConsecutiveSpacesInLocales,
|
||
{
|
||
valid: [
|
||
{ code: '{ "key": "one space" }' },
|
||
{ code: '{ "key": "no whitespace" }' },
|
||
],
|
||
invalid: [
|
||
{
|
||
code: '{ "key": "two spaces" }',
|
||
errors: [{ messageId: 'consecutiveSpaces' }],
|
||
output: '{ "key": "two spaces" }',
|
||
},
|
||
{
|
||
code: '{ "key": "three spaces" }',
|
||
errors: [{ messageId: 'consecutiveSpaces' }],
|
||
output: '{ "key": "three spaces" }',
|
||
},
|
||
{
|
||
// \t then space → two consecutive whitespace chars
|
||
code: '{ "key": "tab\\t and" }',
|
||
errors: [{ messageId: 'consecutiveSpaces' }],
|
||
output: '{ "key": "tab and" }',
|
||
},
|
||
],
|
||
}
|
||
)
|
||
|
||
jsonRuleTester.run(
|
||
'no-straight-apostrophes-in-locales',
|
||
noStraightApostrophesInLocales,
|
||
{
|
||
valid: [
|
||
{ code: '{ "key": "no apostrophe" }' },
|
||
{ code: '{ "key": "it’s curly" }' },
|
||
],
|
||
invalid: [
|
||
{
|
||
code: `{ "key": "it's straight" }`,
|
||
errors: [{ messageId: 'straightApostrophe' }],
|
||
output: '{ "key": "it’s straight" }',
|
||
},
|
||
],
|
||
}
|
||
)
|
||
|
||
jsonRuleTester.run('sorted-keys-in-locales', sortedKeysInLocales, {
|
||
valid: [
|
||
{ code: '{\n "a": "1",\n "b": "2"\n}\n' },
|
||
{ code: '{}' },
|
||
{ code: '{ "only": "one" }' },
|
||
],
|
||
invalid: [
|
||
{
|
||
code: '{\n "b": "2",\n "a": "1"\n}\n',
|
||
errors: [{ messageId: 'unsorted' }],
|
||
output: '{\n "a": "1",\n "b": "2"\n}\n',
|
||
},
|
||
],
|
||
})
|
||
|
||
jsonRuleTester.run('french-typography-in-locales', frenchTypographyInLocales, {
|
||
valid: [
|
||
{ code: '{ "key": "Bonjour ?" }' },
|
||
{ code: '{ "key": "Liste :" }' },
|
||
{ code: '{ "key": "« contenu »" }' },
|
||
{ code: '{ "key": "abc 123" }' },
|
||
{ code: '{ "key": "10 %" }' },
|
||
],
|
||
invalid: [
|
||
{
|
||
code: '{ "key": "Bonjour?" }',
|
||
errors: 1,
|
||
output: '{ "key": "Bonjour ?" }',
|
||
},
|
||
{
|
||
code: '{ "key": "Bonjour ?" }',
|
||
errors: 1,
|
||
output: '{ "key": "Bonjour ?" }',
|
||
},
|
||
{
|
||
code: '{ "key": "Liste:" }',
|
||
errors: 1,
|
||
output: '{ "key": "Liste :" }',
|
||
},
|
||
{
|
||
code: '{ "key": "«contenu»" }',
|
||
errors: 2,
|
||
output: '{ "key": "« contenu »" }',
|
||
},
|
||
{
|
||
code: '{ "key": "10%" }',
|
||
errors: 1,
|
||
output: '{ "key": "10 %" }',
|
||
},
|
||
{
|
||
code: '{ "key": "sûr(e)" }',
|
||
errors: [
|
||
{
|
||
message:
|
||
'expected point médian "·" instead of "(e)" for inclusive writing',
|
||
suggestions: [
|
||
{
|
||
desc: 'Replace "(e)" with "·e"',
|
||
output: '{ "key": "sûr·e" }',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
})
|