[web] Add ESLint rules for locale JSON (#34221)

* 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
This commit is contained in:
Antoine Clausse
2026-06-10 08:06:44 +00:00
committed by Copybot
co-authored by Claude Opus 4.7
parent 998f3a598a
commit 9dde7d3d71
26 changed files with 741 additions and 267 deletions
@@ -0,0 +1,160 @@
// French typographic spacing rules for JSON string values.
// Per https://typographisme.net/post/Les-espaces-typographiques-et-le-web
// - NNBSP before ? ! ;
// - NBSP before :
// - NBSP inside guillemets (after «, before ») — keeps the
// guillemet attached to its content so it cannot be orphaned at a
// line break
// - NNBSP between digit groups (thousands separator)
// - NBSP between a number and a following word/unit (50 Mo, 5 jours)
// - NBSP before %
// - point médian "·" instead of parenthetical (e) for inclusive writing
const NBSP = ' '
const NNBSP = ' '
function autoFix(value) {
let v = value
// Normalize whitespace before ? ! ; to NNBSP
v = v.replace(/\s*([?!;])/g, `${NNBSP}$1`)
v = v.replace(/([^\s])([?!;])/g, `$1${NNBSP}$2`)
// Normalize whitespace before : (after a letter, not before /) to NBSP
v = v.replace(/(\p{L})\s*:(?!\/)/gu, `$1${NBSP}:`)
// NBSP after «
v = v.replace(/«\s*/g, `«${NBSP}`)
// NBSP before »
v = v.replace(/\s*»/g, `${NBSP}»`)
// NNBSP between digits (thousands separator)
v = v.replace(/(\d)\s+(\d)/g, `$1${NNBSP}$2`)
// NBSP between digit and following letter (number + unit/word)
v = v.replace(/(\d)\s+(\p{L})/gu, `$1${NBSP}$2`)
// NBSP before %
v = v.replace(/(\d)\s*%/g, `$1${NBSP}%`)
return v
}
function lint(value) {
const violations = []
for (const m of value.matchAll(/(.)([?!;])/g)) {
if (m[1] !== NNBSP) {
violations.push({
message: `expected NNBSP before "${m[2]}", got ${JSON.stringify(m[1])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/(\p{L})(\s*):(?!\/)/gu)) {
if (m[2] !== NBSP) {
violations.push({
message: `expected NBSP before ":" after letter ${JSON.stringify(m[1])}, got ${JSON.stringify(m[2])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/«(.)/g)) {
if (m[1] !== NBSP) {
violations.push({
message: `expected NBSP after "«", got ${JSON.stringify(m[1])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/(.)»/g)) {
if (m[1] !== NBSP) {
violations.push({
message: `expected NBSP before "»", got ${JSON.stringify(m[1])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/(\d)(\s)(\d)/g)) {
if (m[2] !== NNBSP) {
violations.push({
message: `expected NNBSP between digits "${m[1]}${m[3]}", got ${JSON.stringify(m[2])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/(\d)(\s)(\p{L})/gu)) {
if (m[2] !== NBSP) {
violations.push({
message: `expected NBSP between digit "${m[1]}" and "${m[3]}", got ${JSON.stringify(m[2])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/(\d)(\s*)%/g)) {
if (m[2] !== NBSP) {
violations.push({
message: `expected NBSP before "%" after "${m[1]}", got ${JSON.stringify(m[2])}`,
fixable: true,
})
}
}
if (value.includes('(e)')) {
violations.push({
message:
'expected point médian "·" instead of "(e)" for inclusive writing',
fixable: false, // suggestion-only
})
}
return violations
}
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
hasSuggestions: true,
docs: {
description:
'Enforce French typographic spacing in JSON string values (NNBSP before ? ! ;, NBSP before : and inside « », etc.).',
},
schema: [],
},
create(context) {
return {
Member(node) {
if (node.value.type !== 'String') return
const original = node.value.value
const violations = lint(original)
if (violations.length === 0) return
const fixed = autoFix(original)
const replacement = JSON.stringify(fixed)
const inclusiveSuggestion = original.includes('(e)')
? JSON.stringify(original.replace(/\(e\)/g, '·e'))
: null
for (const v of violations) {
context.report({
node: node.value,
message: v.message,
...(v.fixable && fixed !== original
? {
fix(fixer) {
return fixer.replaceText(node.value, replacement)
},
}
: {}),
...(!v.fixable && inclusiveSuggestion
? {
suggest: [
{
desc: 'Replace "(e)" with "·e"',
fix(fixer) {
return fixer.replaceText(
node.value,
inclusiveSuggestion
)
},
},
],
}
: {}),
})
}
},
}
},
}
+6
View File
@@ -15,5 +15,11 @@ module.exports = {
'require-loading-label': require('./require-loading-label'),
'require-cio-snake-case-properties': require('./require-cio-snake-case-properties'),
'no-throw-in-callback': require('./no-throw-in-callback'),
'no-consecutive-spaces-in-locales': require('./no-consecutive-spaces-in-locales'),
'no-straight-apostrophes-in-locales': require('./no-straight-apostrophes-in-locales'),
'french-typography-in-locales': require('./french-typography-in-locales'),
'sorted-keys-in-locales': require('./sorted-keys-in-locales'),
'locale-variables-match-en': require('./locale-variables-match-en'),
'no-orphan-locale-keys': require('./no-orphan-locale-keys'),
},
}
@@ -0,0 +1,93 @@
// Ensure variable placeholders (`__name__`) in non-en locale values match
// those in the corresponding en.json key.
//
// Apply this rule to all non-en locale JSON files.
const fs = require('node:fs')
const Path = require('node:path')
// Placeholders always available in translations (injected by the i18n layer
// or globally defined as defaults).
const GLOBALS = new Set(['__appName__'])
// Keys whose translations are allowed to omit specific base placeholders.
const IGNORE_NESTING_FOR = {
over_x_templates_easy_getting_started: ['__templates__'],
all_packages_and_templates: ['__templatesLink__'],
}
function extractPlaceholders(str) {
return Array.from(str.matchAll(/__.*?__/g), m => m[0])
}
const enCache = new Map()
function loadEn(localesDir) {
if (enCache.has(localesDir)) return enCache.get(localesDir)
const path = Path.join(localesDir, 'en.json')
const data = JSON.parse(fs.readFileSync(path, 'utf8'))
enCache.set(localesDir, data)
return data
}
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Ensure variable placeholders in non-en locale values match the en.json base.',
},
schema: [],
messages: {
missing:
'Translation for key "{{key}}" is missing placeholder(s) {{placeholders}} present in en.json.',
extra:
'Translation for key "{{key}}" has placeholder(s) {{placeholders}} not present in en.json.',
},
},
create(context) {
const filename = context.filename
const localesDir = Path.dirname(filename)
if (Path.basename(filename) === 'en.json') return {}
let base
try {
base = loadEn(localesDir)
} catch {
return {}
}
return {
Member(node) {
if (node.value.type !== 'String') return
const key = node.name.value
if (!(key in base)) return // orphan key — covered by no-orphan-locale-keys
const basePlaceholders = extractPlaceholders(base[key])
const targetPlaceholders = extractPlaceholders(node.value.value)
const ignored = IGNORE_NESTING_FOR[key] || []
const missing = basePlaceholders.filter(
p => !targetPlaceholders.includes(p) && !ignored.includes(p)
)
const extra = targetPlaceholders.filter(
p => !basePlaceholders.includes(p) && !GLOBALS.has(p)
)
if (missing.length > 0) {
context.report({
node: node.value,
messageId: 'missing',
data: { key, placeholders: missing.join(', ') },
})
}
if (extra.length > 0) {
context.report({
node: node.value,
messageId: 'extra',
data: { key, placeholders: extra.join(', ') },
})
}
},
}
},
}
@@ -0,0 +1,37 @@
// Reject runs of two or more consecutive whitespace characters in JSON string
// values. Catches ASCII spaces, NBSP, NNBSP, tabs, and other Unicode whitespace.
const CONSECUTIVE_WHITESPACE = /\s{2,}/
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description:
'Disallow runs of two or more consecutive whitespace characters in JSON string values (typically locale files).',
},
schema: [],
messages: {
consecutiveSpaces:
'Locale value contains a run of consecutive whitespace. Collapse to a single space.',
},
},
create(context) {
return {
Member(node) {
if (node.value.type !== 'String') return
const original = node.value.value
if (!CONSECUTIVE_WHITESPACE.test(original)) return
const fixed = original.replace(/\s{2,}/g, ' ')
context.report({
node: node.value,
messageId: 'consecutiveSpaces',
fix(fixer) {
return fixer.replaceText(node.value, JSON.stringify(fixed))
},
})
},
}
},
}
@@ -0,0 +1,69 @@
// Detect translation keys in non-en locale files that don't exist in
// en.json (orphans).
const fs = require('node:fs')
const Path = require('node:path')
const enCache = new Map()
function loadEnKeys(localesDir) {
if (enCache.has(localesDir)) return enCache.get(localesDir)
const path = Path.join(localesDir, 'en.json')
const data = JSON.parse(fs.readFileSync(path, 'utf8'))
const keys = new Set(Object.keys(data))
enCache.set(localesDir, keys)
return keys
}
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description:
'Detect translation keys in non-en locale files that are not present in en.json.',
},
schema: [],
messages: {
orphan:
'Translation key "{{key}}" is not present in en.json (orphan key).',
},
},
create(context) {
const filename = context.filename
const localesDir = Path.dirname(filename)
if (Path.basename(filename) === 'en.json') return {}
let enKeys
try {
enKeys = loadEnKeys(localesDir)
} catch {
return {}
}
return {
'Document > Object'(node) {
const orphans = node.members.filter(m => !enKeys.has(m.name.value))
if (orphans.length === 0) return
const text = context.sourceCode.text
const parsed = JSON.parse(text)
for (const member of orphans) {
delete parsed[member.name.value]
}
const sortedRemaining = Object.keys(parsed).sort()
const cleaned = JSON.stringify(parsed, sortedRemaining, 2) + '\n'
for (const member of orphans) {
context.report({
node: member,
messageId: 'orphan',
data: { key: member.name.value },
fix(fixer) {
return fixer.replaceTextRange([0, text.length], cleaned)
},
})
}
},
}
},
}
@@ -0,0 +1,36 @@
// Reject straight apostrophes (') in JSON string values. Straight apostrophes
// in Angular templates can lead to XSS; use the right single quotation mark
// ’ (U+2019) instead. See https://github.com/overleaf/issues/issues/4478
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description:
'Disallow straight apostrophes in JSON string values (typically locale files).',
},
schema: [],
messages: {
straightApostrophe:
"Locale value contains a straight apostrophe ('). Use the right single quotation mark ’ (U+2019) instead.",
},
},
create(context) {
return {
Member(node) {
if (node.value.type !== 'String') return
const original = node.value.value
if (!original.includes("'")) return
const fixed = original.replace(/'/g, '’')
context.report({
node: node.value,
messageId: 'straightApostrophe',
fix(fixer) {
return fixer.replaceText(node.value, JSON.stringify(fixed))
},
})
},
}
},
}
+1
View File
@@ -8,6 +8,7 @@
"lodash": "^4.18.1"
},
"devDependencies": {
"@eslint/json": "^2.0.0",
"@typescript-eslint/parser": "^8.59.4"
},
"peerDependencies": {
+125
View File
@@ -1,5 +1,6 @@
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')
@@ -7,6 +8,10 @@ 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: {
@@ -16,6 +21,11 @@ const ruleTester = new RuleTester({
},
})
const jsonRuleTester = new RuleTester({
plugins: { json },
language: 'json/json',
})
ruleTester.run('prefer-kebab-url', preferKebabUrl, {
valid: [
{ code: `app.get('/foo-bar')` },
@@ -329,3 +339,118 @@ ruleTester.run('no-throw-in-callback', noThrowInCallback, {
},
],
})
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" }',
},
],
},
],
},
],
})
@@ -0,0 +1,43 @@
// Enforce alphabetically sorted top-level keys in JSON files.
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description:
'Require top-level keys to be sorted alphabetically in JSON files (typically locale files).',
},
schema: [],
messages: {
unsorted:
'Top-level keys are not sorted alphabetically. Key "{{key}}" should come before "{{predecessor}}".',
},
},
create(context) {
return {
'Document > Object'(node) {
const members = node.members
for (let i = 1; i < members.length; i++) {
const prev = members[i - 1].name.value
const curr = members[i].name.value
if (curr < prev) {
const text = context.sourceCode.text
const parsed = JSON.parse(text)
const sortedKeys = Object.keys(parsed).sort()
const sorted = JSON.stringify(parsed, sortedKeys, 2) + '\n'
context.report({
node: members[i],
messageId: 'unsorted',
data: { key: curr, predecessor: prev },
fix(fixer) {
return fixer.replaceTextRange([0, text.length], sorted)
},
})
return // one violation per file is enough; the fix re-sorts everything
}
}
},
}
},
}