french-typography-in-locales.js 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. // French typographic spacing rules for JSON string values.
  2. // Per https://typographisme.net/post/Les-espaces-typographiques-et-le-web
  3. // - NNBSP before ? ! ;
  4. // - NBSP before :
  5. // - NBSP inside guillemets (after «, before ») — keeps the
  6. // guillemet attached to its content so it cannot be orphaned at a
  7. // line break
  8. // - NNBSP between digit groups (thousands separator)
  9. // - NBSP between a number and a following word/unit (50 Mo, 5 jours)
  10. // - NBSP before %
  11. // - point médian "·" instead of parenthetical (e) for inclusive writing
  12. const NBSP = ' '
  13. const NNBSP = ' '
  14. function autoFix(value) {
  15. let v = value
  16. // Normalize whitespace before ? ! ; to NNBSP
  17. v = v.replace(/\s*([?!;])/g, `${NNBSP}$1`)
  18. v = v.replace(/([^\s])([?!;])/g, `$1${NNBSP}$2`)
  19. // Normalize whitespace before : (after a letter, not before /) to NBSP
  20. v = v.replace(/(\p{L})\s*:(?!\/)/gu, `$1${NBSP}:`)
  21. // NBSP after «
  22. v = v.replace(/«\s*/g, `«${NBSP}`)
  23. // NBSP before »
  24. v = v.replace(/\s*»/g, `${NBSP}»`)
  25. // NNBSP between digits (thousands separator)
  26. v = v.replace(/(\d)\s+(\d)/g, `$1${NNBSP}$2`)
  27. // NBSP between digit and following letter (number + unit/word)
  28. v = v.replace(/(\d)\s+(\p{L})/gu, `$1${NBSP}$2`)
  29. // NBSP before %
  30. v = v.replace(/(\d)\s*%/g, `$1${NBSP}%`)
  31. return v
  32. }
  33. function lint(value) {
  34. const violations = []
  35. for (const m of value.matchAll(/(.)([?!;])/g)) {
  36. if (m[1] !== NNBSP) {
  37. violations.push({
  38. message: `expected NNBSP before "${m[2]}", got ${JSON.stringify(m[1])}`,
  39. fixable: true,
  40. })
  41. }
  42. }
  43. for (const m of value.matchAll(/(\p{L})(\s*):(?!\/)/gu)) {
  44. if (m[2] !== NBSP) {
  45. violations.push({
  46. message: `expected NBSP before ":" after letter ${JSON.stringify(m[1])}, got ${JSON.stringify(m[2])}`,
  47. fixable: true,
  48. })
  49. }
  50. }
  51. for (const m of value.matchAll(/«(.)/g)) {
  52. if (m[1] !== NBSP) {
  53. violations.push({
  54. message: `expected NBSP after "«", got ${JSON.stringify(m[1])}`,
  55. fixable: true,
  56. })
  57. }
  58. }
  59. for (const m of value.matchAll(/(.)»/g)) {
  60. if (m[1] !== NBSP) {
  61. violations.push({
  62. message: `expected NBSP before "»", got ${JSON.stringify(m[1])}`,
  63. fixable: true,
  64. })
  65. }
  66. }
  67. for (const m of value.matchAll(/(\d)(\s)(\d)/g)) {
  68. if (m[2] !== NNBSP) {
  69. violations.push({
  70. message: `expected NNBSP between digits "${m[1]}${m[3]}", got ${JSON.stringify(m[2])}`,
  71. fixable: true,
  72. })
  73. }
  74. }
  75. for (const m of value.matchAll(/(\d)(\s)(\p{L})/gu)) {
  76. if (m[2] !== NBSP) {
  77. violations.push({
  78. message: `expected NBSP between digit "${m[1]}" and "${m[3]}", got ${JSON.stringify(m[2])}`,
  79. fixable: true,
  80. })
  81. }
  82. }
  83. for (const m of value.matchAll(/(\d)(\s*)%/g)) {
  84. if (m[2] !== NBSP) {
  85. violations.push({
  86. message: `expected NBSP before "%" after "${m[1]}", got ${JSON.stringify(m[2])}`,
  87. fixable: true,
  88. })
  89. }
  90. }
  91. if (value.includes('(e)')) {
  92. violations.push({
  93. message:
  94. 'expected point médian "·" instead of "(e)" for inclusive writing',
  95. fixable: false, // suggestion-only
  96. })
  97. }
  98. return violations
  99. }
  100. module.exports = {
  101. meta: {
  102. type: 'problem',
  103. fixable: 'code',
  104. hasSuggestions: true,
  105. docs: {
  106. description:
  107. 'Enforce French typographic spacing in JSON string values (NNBSP before ? ! ;, NBSP before : and inside « », etc.).',
  108. },
  109. schema: [],
  110. },
  111. create(context) {
  112. return {
  113. Member(node) {
  114. if (node.value.type !== 'String') return
  115. const original = node.value.value
  116. const violations = lint(original)
  117. if (violations.length === 0) return
  118. const fixed = autoFix(original)
  119. const replacement = JSON.stringify(fixed)
  120. const inclusiveSuggestion = original.includes('(e)')
  121. ? JSON.stringify(original.replace(/\(e\)/g, '·e'))
  122. : null
  123. for (const v of violations) {
  124. context.report({
  125. node: node.value,
  126. message: v.message,
  127. ...(v.fixable && fixed !== original
  128. ? {
  129. fix(fixer) {
  130. return fixer.replaceText(node.value, replacement)
  131. },
  132. }
  133. : {}),
  134. ...(!v.fixable && inclusiveSuggestion
  135. ? {
  136. suggest: [
  137. {
  138. desc: 'Replace "(e)" with "·e"',
  139. fix(fixer) {
  140. return fixer.replaceText(
  141. node.value,
  142. inclusiveSuggestion
  143. )
  144. },
  145. },
  146. ],
  147. }
  148. : {}),
  149. })
  150. }
  151. },
  152. }
  153. },
  154. }