mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
[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:
committed by
Copybot
co-authored by
Claude Opus 4.7
parent
998f3a598a
commit
9dde7d3d71
@@ -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
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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))
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"lodash": "^4.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/json": "^2.0.0",
|
||||
"@typescript-eslint/parser": "^8.59.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user