locale-variables-match-en.js 2.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  1. // Ensure variable placeholders (`__name__`) in non-en locale values match
  2. // those in the corresponding en.json key.
  3. //
  4. // Apply this rule to all non-en locale JSON files.
  5. const fs = require('node:fs')
  6. const Path = require('node:path')
  7. // Placeholders always available in translations (injected by the i18n layer
  8. // or globally defined as defaults).
  9. const GLOBALS = new Set(['__appName__'])
  10. // Keys whose translations are allowed to omit specific base placeholders.
  11. const IGNORE_NESTING_FOR = {
  12. over_x_templates_easy_getting_started: ['__templates__'],
  13. all_packages_and_templates: ['__templatesLink__'],
  14. }
  15. function extractPlaceholders(str) {
  16. return Array.from(str.matchAll(/__.*?__/g), m => m[0])
  17. }
  18. const enCache = new Map()
  19. function loadEn(localesDir) {
  20. if (enCache.has(localesDir)) return enCache.get(localesDir)
  21. const path = Path.join(localesDir, 'en.json')
  22. const data = JSON.parse(fs.readFileSync(path, 'utf8'))
  23. enCache.set(localesDir, data)
  24. return data
  25. }
  26. module.exports = {
  27. meta: {
  28. type: 'problem',
  29. docs: {
  30. description:
  31. 'Ensure variable placeholders in non-en locale values match the en.json base.',
  32. },
  33. schema: [],
  34. messages: {
  35. missing:
  36. 'Translation for key "{{key}}" is missing placeholder(s) {{placeholders}} present in en.json.',
  37. extra:
  38. 'Translation for key "{{key}}" has placeholder(s) {{placeholders}} not present in en.json.',
  39. },
  40. },
  41. create(context) {
  42. const filename = context.filename
  43. const localesDir = Path.dirname(filename)
  44. if (Path.basename(filename) === 'en.json') return {}
  45. let base
  46. try {
  47. base = loadEn(localesDir)
  48. } catch {
  49. return {}
  50. }
  51. return {
  52. Member(node) {
  53. if (node.value.type !== 'String') return
  54. const key = node.name.value
  55. if (!(key in base)) return // orphan key — covered by no-orphan-locale-keys
  56. const basePlaceholders = extractPlaceholders(base[key])
  57. const targetPlaceholders = extractPlaceholders(node.value.value)
  58. const ignored = IGNORE_NESTING_FOR[key] || []
  59. const missing = basePlaceholders.filter(
  60. p => !targetPlaceholders.includes(p) && !ignored.includes(p)
  61. )
  62. const extra = targetPlaceholders.filter(
  63. p => !basePlaceholders.includes(p) && !GLOBALS.has(p)
  64. )
  65. if (missing.length > 0) {
  66. context.report({
  67. node: node.value,
  68. messageId: 'missing',
  69. data: { key, placeholders: missing.join(', ') },
  70. })
  71. }
  72. if (extra.length > 0) {
  73. context.report({
  74. node: node.value,
  75. messageId: 'extra',
  76. data: { key, placeholders: extra.join(', ') },
  77. })
  78. }
  79. },
  80. }
  81. },
  82. }