Repository: sandinmyjoints/equivalency
Pull request #47: Add rules-fr for French
Author: login: baldwinjj
Created: 2025-04-14T17:45:44Z | Merged: 2025-04-14T21:07:52Z

@mdzeng 

Add new rules for French

* Create new file /lib/rules-fr.js and set up the common punctuations and symbols
```
// A set of predefined rules that are especially applicable to French strings.

const en = require('./rules-en');
const { RemoveRule, ReplaceRule} = require('./rules');

const COMMON_PUNCTUATION_STR = en.COMMON_PUNCTUATION_STR + `«»`;
const COMMON_SYMBOLS_STR = en.COMMON_SYMBOLS_STR + `€`;
const COMMON_PUNCTUATION_AND_SYMBOLS_STR = COMMON_PUNCTUATION_STR + COMMON_SYMBOLS_STR;
const COMMON_PUNCTUATION_AND_SYMBOLS = new RemoveRule(COMMON_PUNCTUATION_AND_SYMBOLS_STR, {
  name: 'common punctuation and symbols'
});

module.exports = {
  COMMON_PUNCTUATION_AND_SYMBOLS,
};
```
* Create a new replace rule LIGATURES that replaces "æ” with “ae” and “œ” with “oe”

* Create a new function capitalVowelAccents that takes in two strings:
    * If one string has capital vowels and none of the capital vowels have accents, the other string should remove accents at the indexes of the capital vowels in the initial string. 
    * ie. capitalVowelAccents('ELEVE', 'élève)
    * ELEVE: None of the capital vowels have accents
    * élève: replace é with “e”, replace “è” with “e” ⇒ eleve
* Create new function rule capitalVowelAccentsRule

cc @sandinmyjoints 


Diff:
diff --git a/index.test.js b/index.test.js
index 2b73443..6fe50ba 100644
--- a/index.test.js
+++ b/index.test.js
@@ -1003,6 +1003,98 @@ describe('Real-world usage', () => {
         });
       });
     });
+
+    describe('fr', () => {
+      const frEquivalency = new Equivalency()
+        .doesntMatter(Equivalency.UNICODE_NORMALIZATION)
+        .doesntMatter(Equivalency.WHITESPACE_DIFFERENCES)
+        .doesntMatter(Equivalency.fr.CAPITAL_VOWEL_ACCENTS)
+        .doesntMatter(Equivalency.CAPITALIZATION)
+        .doesntMatter(Equivalency.fr.COMMON_PUNCTUATION_AND_SYMBOLS)
+        .doesntMatter(Equivalency.fr.LIGATURES)
+        .matters('-');
+      it('should mark candidates equivalent that we want to count as equivalent', () => {
+        const theCorrectAnswer = "œuf, élève, m'appelle, et cætera";
+
+        const candidates = [
+          "œuf, élève, m'appelle, et cætera", // exact match
+          'œuf, élève, m‘appelle, et cætera', // open smart quote
+          'œuf, élève, m’appelle, et cætera', // close smart quote
+          "« œuf élève m'appelle et cætera »", // « and » are common punctuation
+          "€œuf élève m'appelle et cætera", // € symbol
+          "oeuf, élève, m'appelle, et caetera", // ligature replacement match
+          "œuf, ÉLÈVE, m'appelle, et cætera", // correct capital accents
+          "œuf, ELEVE, m'appelle, et cætera", // no accents
+          "œuf, Elève, m'appelle, et cætera", // no capital accents
+        ];
+
+        candidates.forEach(candidate => {
+          const { isEquivalent } = frEquivalency.equivalent(
+            theCorrectAnswer,
+            candidate
+          );
+          expect(isEquivalent).toBe(true);
+        });
+      });
+
+      it('should mark equivalent NFD => NFC', () => {
+        const theCorrectAnswer = 'œuf, élève, et cætera'.normalize('NFD');
+
+        const candidates = [
+          'œuf, élève, et cætera', // exact match
+          '« œuf élève et cætera »', // « and » are common punctuation
+          '€œuf élève et cætera', // € symbol
+          'oeuf, élève, et caetera', // ligature replacement match
+          'œuf, ÉLÈVE, et cætera', // correct capital accents
+          'œuf, ELEVE, et cætera', // no accents
+          'œuf, Elève, et cætera', // no capital accents
+        ].map(str => str.normalize('NFC'));
+
+        candidates.forEach(candidate => {
+          const { isEquivalent } = frEquivalency.equivalent(
+            theCorrectAnswer,
+            candidate
+          );
+          expect(isEquivalent).toBe(true);
+        });
+      });
+
+      it('should mark equivalent NFC => NFD', () => {
+        const theCorrectAnswer = 'œuf, élève, et cætera'.normalize('NFC');
+
+        const candidates = [
+          'œuf, élève, et cætera', // exact match
+          '« œuf élève et cætera »', // « and » are common punctuation
+          '€œuf élève et cætera', // € symbol
+          'oeuf, élève, et caetera', // ligature replacement match
+          'œuf, ÉLÈVE, et cætera', // correct capital accents
+          'œuf, ELEVE, et cætera', // no accents
+          'œuf, Elève, et cætera', // no capital accents
+        ].map(str => str.normalize('NFD'));
+
+        candidates.forEach(candidate => {
+          const { isEquivalent } = frEquivalency.equivalent(
+            theCorrectAnswer,
+            candidate
+          );
+          expect(isEquivalent).toBe(true);
+        });
+      });
+
+      it('should mark candidates inequivalent that we dont want to count as equivalent', () => {
+        const theCorrectAnswer = 'œuf, élève, et cætera';
+
+        const candidates = ['œuf, ÉLEVE, et cætera', 'œuf, ELÈVE, et cætera'];
+
+        candidates.forEach(candidate => {
+          const { isEquivalent } = frEquivalency.equivalent(
+            theCorrectAnswer,
+            candidate
+          );
+          expect(isEquivalent).toBe(false);
+        });
+      });
+    });
   });
 
   describe('editDistance (diacritics agnostic)', () => {
diff --git a/lib/index.js b/lib/index.js
index 8db6682..773d695 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -29,4 +29,5 @@ module.exports = {
   wordPrefix: rules.wordPrefix,
   en: require('./rules-en'),
   es: require('./rules-es'),
+  fr: require('./rules-fr'),
 };
diff --git a/lib/rules-fr.js b/lib/rules-fr.js
new file mode 100644
index 0000000..353166b
--- /dev/null
+++ b/lib/rules-fr.js
@@ -0,0 +1,105 @@
+// A set of predefined rules that are especially applicable to French strings.
+
+const en = require('./rules-en');
+const { RemoveRule, FunctionRule } = require('./rules');
+const normalize = require('./normalize');
+
+const COMMON_PUNCTUATION_STR = en.COMMON_PUNCTUATION_STR + `«»`;
+const COMMON_SYMBOLS_STR = en.COMMON_SYMBOLS_STR + `€`;
+const COMMON_PUNCTUATION_AND_SYMBOLS_STR =
+  COMMON_PUNCTUATION_STR + COMMON_SYMBOLS_STR;
+const COMMON_PUNCTUATION_AND_SYMBOLS = new RemoveRule(
+  COMMON_PUNCTUATION_AND_SYMBOLS_STR,
+  {
+    name: 'common punctuation and symbols',
+  }
+);
+
+const replaceLigatures = str => {
+  return str
+    .replace(/æ/g, 'ae')
+    .replace(/Æ/g, 'AE')
+    .replace(/œ/g, 'oe')
+    .replace(/Œ/g, 'OE');
+};
+
+const LIGATURES = new FunctionRule(
+  (s1, s2) => {
+    return [replaceLigatures(s1), replaceLigatures(s2)];
+  },
+  { name: 'ligatures replaced' }
+);
+
+const DECOMPOSED_ACCENTS = ['́', '̀'];
+
+const isAccentedCharacter = char => {
+  const normalized = normalize(char, 'NFD').split('');
+  return normalized.slice(1).some(char => DECOMPOSED_ACCENTS.includes(char));
+};
+
+const isUppercase = char => {
+  return char === char.toUpperCase();
+};
+
+const isVowelCharacter = char => {
+  return ['A', 'E', 'I', 'O', 'U'].includes(
+    normalize(char, 'NFD')[0].toUpperCase()
+  );
+};
+
+const stripAccents = str => {
+  return normalize(str, 'NFC')
+    .split('')
+    .map(char =>
+      normalize(
+        normalize(char, 'NFD')
+          .split('')
+          .filter(char => !DECOMPOSED_ACCENTS.includes(char))
+          .join(''),
+        'NFC'
+      )
+    )
+    .join('');
+};
+
+const asymmetricCapitalVowelAccents = (source, target) => {
+  const sourceArr = normalize(source, 'NFC').split('');
+  const targetArr = normalize(target, 'NFC').split('');
+
+  const shouldModify = sourceArr
+    .filter(char => isVowelCharacter(char) && isUppercase(char))
+    .every(char => !isAccentedCharacter(char));
+
+  return shouldModify
+    ? targetArr
+      .map((char, i) => {
+        if (
+          sourceArr[i] &&
+            isUppercase(sourceArr[i]) &&
+            isVowelCharacter(sourceArr[i]) &&
+            isVowelCharacter(char) &&
+            isAccentedCharacter(char)
+        ) {
+          return stripAccents(char);
+        }
+        return char;
+      })
+      .join('')
+    : target;
+};
+
+const capitalVowelAccents = (str1, str2) => {
+  const res1 = asymmetricCapitalVowelAccents(str2, str1);
+  const res2 = asymmetricCapitalVowelAccents(str1, str2);
+  return [res1, res2];
+};
+
+const capitalVowelAccentsRule = new FunctionRule(capitalVowelAccents, {
+  name: 'capital vowel accents',
+});
+
+module.exports = {
+  CAPITAL_VOWEL_ACCENTS: capitalVowelAccentsRule,
+  COMMON_PUNCTUATION_AND_SYMBOLS,
+  LIGATURES,
+};


Reviews:
user: login: baldwinjj
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-14T17:53:22Z
user: login: baldwinjj
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-14T17:55:13Z
user: login: baldwinjj
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-14T17:59:32Z
user: login: baldwinjj
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-14T18:03:12Z
user: login: sandinmyjoints
body: LGTM! Welcome to equivalency, French ! 🇫🇷 
state: APPROVED
author_association: OWNER
submitted_at: 2025-04-14T20:25:00Z

Review comments:
diff_hunk: @@ -1003,6 +1003,98 @@ describe('Real-world usage', () => {
         });
       });
     });
+
+    describe('fr', () => {
+      const frEquivalency = new Equivalency()
+        .doesntMatter(Equivalency.UNICODE_NORMALIZATION)
+        .doesntMatter(Equivalency.WHITESPACE_DIFFERENCES)
+        .doesntMatter(Equivalency.fr.CAPITAL_VOWEL_ACCENTS)
+        .doesntMatter(Equivalency.CAPITALIZATION)
path: index.test.js
user: login: baldwinjj
body: The order is important here, `CAPITAL_VOWEL_ACCENTS` _must_ come before `CAPITALIZATION` when used or it will have no effect.
created_at: 2025-04-14T17:53:22Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,105 @@
+// A set of predefined rules that are especially applicable to French strings.
+
+const en = require('./rules-en');
+const { RemoveRule, FunctionRule } = require('./rules');
+const normalize = require('./normalize');
+
+const COMMON_PUNCTUATION_STR = en.COMMON_PUNCTUATION_STR + `«»`;
+const COMMON_SYMBOLS_STR = en.COMMON_SYMBOLS_STR + `€`;
+const COMMON_PUNCTUATION_AND_SYMBOLS_STR =
+  COMMON_PUNCTUATION_STR + COMMON_SYMBOLS_STR;
+const COMMON_PUNCTUATION_AND_SYMBOLS = new RemoveRule(
+  COMMON_PUNCTUATION_AND_SYMBOLS_STR,
+  {
+    name: 'common punctuation and symbols',
+  }
+);
+
+const replaceLigatures = str => {
+  return str
+    .replace(/æ/g, 'ae')
+    .replace(/Æ/g, 'AE')
+    .replace(/œ/g, 'oe')
+    .replace(/Œ/g, 'OE');
+};
+
+const LIGATURES = new FunctionRule(
+  (s1, s2) => {
+    return [replaceLigatures(s1), replaceLigatures(s2)];
+  },
+  { name: 'ligatures replaced' }
+);
+
+const DECOMPOSED_ACCENTS = ['́', '̀'];
+
+const isAccentedCharacter = char => {
+  const normalized = normalize(char, 'NFD').split('');
+  return normalized.slice(1).some(char => DECOMPOSED_ACCENTS.includes(char));
+};
path: lib/rules-fr.js
user: login: baldwinjj
body: [String.prototype.normalize()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize) documentation is super helpful here
created_at: 2025-04-14T17:55:13Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,105 @@
+// A set of predefined rules that are especially applicable to French strings.
+
+const en = require('./rules-en');
+const { RemoveRule, FunctionRule } = require('./rules');
+const normalize = require('./normalize');
+
+const COMMON_PUNCTUATION_STR = en.COMMON_PUNCTUATION_STR + `«»`;
+const COMMON_SYMBOLS_STR = en.COMMON_SYMBOLS_STR + `€`;
+const COMMON_PUNCTUATION_AND_SYMBOLS_STR =
+  COMMON_PUNCTUATION_STR + COMMON_SYMBOLS_STR;
+const COMMON_PUNCTUATION_AND_SYMBOLS = new RemoveRule(
+  COMMON_PUNCTUATION_AND_SYMBOLS_STR,
+  {
+    name: 'common punctuation and symbols',
+  }
+);
+
+const replaceLigatures = str => {
+  return str
+    .replace(/æ/g, 'ae')
+    .replace(/Æ/g, 'AE')
+    .replace(/œ/g, 'oe')
+    .replace(/Œ/g, 'OE');
+};
+
+const LIGATURES = new FunctionRule(
+  (s1, s2) => {
+    return [replaceLigatures(s1), replaceLigatures(s2)];
+  },
+  { name: 'ligatures replaced' }
+);
+
+const DECOMPOSED_ACCENTS = ['́', '̀'];
+
+const isAccentedCharacter = char => {
+  const normalized = normalize(char, 'NFD').split('');
+  return normalized.slice(1).some(char => DECOMPOSED_ACCENTS.includes(char));
+};
+
+const isUppercase = char => {
+  return char === char.toUpperCase();
+};
+
+const isVowelCharacter = char => {
+  return ['A', 'E', 'I', 'O', 'U'].includes(
+    normalize(char, 'NFD')[0].toUpperCase()
+  );
+};
+
+const stripAccents = str => {
+  return normalize(str, 'NFC')
+    .split('')
+    .map(char =>
+      normalize(
+        normalize(char, 'NFD')
+          .split('')
+          .filter(char => !DECOMPOSED_ACCENTS.includes(char))
+          .join(''),
+        'NFC'
+      )
+    )
+    .join('');
+};
+
+const asymmetricCapitalVowelAccents = (source, target) => {
+  const sourceArr = normalize(source, 'NFC').split('');
+  const targetArr = normalize(target, 'NFC').split('');
+
+  const shouldModify = sourceArr
+    .filter(char => isVowelCharacter(char) && isUppercase(char))
+    .every(char => !isAccentedCharacter(char));
+
+  return shouldModify
+    ? targetArr
+      .map((char, i) => {
+        if (
+          sourceArr[i] &&
+            isUppercase(sourceArr[i]) &&
+            isVowelCharacter(sourceArr[i]) &&
+            isVowelCharacter(char) &&
+            isAccentedCharacter(char)
+        ) {
+          return stripAccents(char);
+        }
+        return char;
+      })
+      .join('')
+    : target;
+};
+
+const capitalVowelAccents = (str1, str2) => {
+  const res1 = asymmetricCapitalVowelAccents(str2, str1);
+  const res2 = asymmetricCapitalVowelAccents(str1, str2);
+  return [res1, res2];
path: lib/rules-fr.js
user: login: baldwinjj
body: There is no precedent for asymmetric rules in the equivalency library as far as I could tell. The requirements were not specific so I made the changes bidirectional like the rest of the rules. This rule is a little strange because it uses properties from one string to make changes to the other.
created_at: 2025-04-14T17:59:32Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,105 @@
+// A set of predefined rules that are especially applicable to French strings.
+
+const en = require('./rules-en');
+const { RemoveRule, FunctionRule } = require('./rules');
+const normalize = require('./normalize');
+
+const COMMON_PUNCTUATION_STR = en.COMMON_PUNCTUATION_STR + `«»`;
+const COMMON_SYMBOLS_STR = en.COMMON_SYMBOLS_STR + `€`;
+const COMMON_PUNCTUATION_AND_SYMBOLS_STR =
+  COMMON_PUNCTUATION_STR + COMMON_SYMBOLS_STR;
+const COMMON_PUNCTUATION_AND_SYMBOLS = new RemoveRule(
+  COMMON_PUNCTUATION_AND_SYMBOLS_STR,
+  {
+    name: 'common punctuation and symbols',
+  }
+);
+
+const replaceLigatures = str => {
+  return str
+    .replace(/æ/g, 'ae')
+    .replace(/Æ/g, 'AE')
+    .replace(/œ/g, 'oe')
+    .replace(/Œ/g, 'OE');
+};
+
+const LIGATURES = new FunctionRule(
+  (s1, s2) => {
+    return [replaceLigatures(s1), replaceLigatures(s2)];
+  },
+  { name: 'ligatures replaced' }
+);
path: lib/rules-fr.js
user: login: baldwinjj
body: Since the replace rule only operates on one character at a time, I made this a FunctionRule so that both ligature candidates could be specified. Note that all FunctionRules are run _after_ the other rules.
created_at: 2025-04-14T18:03:12Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,105 @@
+// A set of predefined rules that are especially applicable to French strings.
+
+const en = require('./rules-en');
+const { RemoveRule, FunctionRule } = require('./rules');
+const normalize = require('./normalize');
+
+const COMMON_PUNCTUATION_STR = en.COMMON_PUNCTUATION_STR + `«»`;
+const COMMON_SYMBOLS_STR = en.COMMON_SYMBOLS_STR + `€`;
+const COMMON_PUNCTUATION_AND_SYMBOLS_STR =
+  COMMON_PUNCTUATION_STR + COMMON_SYMBOLS_STR;
+const COMMON_PUNCTUATION_AND_SYMBOLS = new RemoveRule(
+  COMMON_PUNCTUATION_AND_SYMBOLS_STR,
+  {
+    name: 'common punctuation and symbols',
+  }
+);
+
+const replaceLigatures = str => {
+  return str
+    .replace(/æ/g, 'ae')
+    .replace(/Æ/g, 'AE')
+    .replace(/œ/g, 'oe')
+    .replace(/Œ/g, 'OE');
+};
+
+const LIGATURES = new FunctionRule(
+  (s1, s2) => {
+    return [replaceLigatures(s1), replaceLigatures(s2)];
+  },
+  { name: 'ligatures replaced' }
+);
+
+const DECOMPOSED_ACCENTS = ['́', '̀'];
+
+const isAccentedCharacter = char => {
+  const normalized = normalize(char, 'NFD').split('');
+  return normalized.slice(1).some(char => DECOMPOSED_ACCENTS.includes(char));
+};
+
+const isUppercase = char => {
+  return char === char.toUpperCase();
+};
+
+const isVowelCharacter = char => {
+  return ['A', 'E', 'I', 'O', 'U'].includes(
+    normalize(char, 'NFD')[0].toUpperCase()
+  );
+};
+
+const stripAccents = str => {
+  return normalize(str, 'NFC')
+    .split('')
+    .map(char =>
+      normalize(
+        normalize(char, 'NFD')
+          .split('')
+          .filter(char => !DECOMPOSED_ACCENTS.includes(char))
+          .join(''),
+        'NFC'
+      )
+    )
+    .join('');
+};
+
+const asymmetricCapitalVowelAccents = (source, target) => {
+  const sourceArr = normalize(source, 'NFC').split('');
+  const targetArr = normalize(target, 'NFC').split('');
+
+  const shouldModify = sourceArr
+    .filter(char => isVowelCharacter(char) && isUppercase(char))
+    .every(char => !isAccentedCharacter(char));
+
+  return shouldModify
+    ? targetArr
+      .map((char, i) => {
+        if (
+          sourceArr[i] &&
+            isUppercase(sourceArr[i]) &&
+            isVowelCharacter(sourceArr[i]) &&
+            isVowelCharacter(char) &&
+            isAccentedCharacter(char)
+        ) {
+          return stripAccents(char);
+        }
+        return char;
+      })
+      .join('')
+    : target;
+};
+
+const capitalVowelAccents = (str1, str2) => {
+  const res1 = asymmetricCapitalVowelAccents(str2, str1);
+  const res2 = asymmetricCapitalVowelAccents(str1, str2);
+  return [res1, res2];
path: lib/rules-fr.js
user: login: sandinmyjoints
body: Agreed it's a novel rule. Your implementation makes sense to me! 

Out of curiosity, what is the use case for capitalVowelAccents?
created_at: 2025-04-14T20:21:32Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,105 @@
+// A set of predefined rules that are especially applicable to French strings.
+
+const en = require('./rules-en');
+const { RemoveRule, FunctionRule } = require('./rules');
+const normalize = require('./normalize');
+
+const COMMON_PUNCTUATION_STR = en.COMMON_PUNCTUATION_STR + `«»`;
+const COMMON_SYMBOLS_STR = en.COMMON_SYMBOLS_STR + `€`;
+const COMMON_PUNCTUATION_AND_SYMBOLS_STR =
+  COMMON_PUNCTUATION_STR + COMMON_SYMBOLS_STR;
+const COMMON_PUNCTUATION_AND_SYMBOLS = new RemoveRule(
+  COMMON_PUNCTUATION_AND_SYMBOLS_STR,
+  {
+    name: 'common punctuation and symbols',
+  }
+);
+
+const replaceLigatures = str => {
+  return str
+    .replace(/æ/g, 'ae')
+    .replace(/Æ/g, 'AE')
+    .replace(/œ/g, 'oe')
+    .replace(/Œ/g, 'OE');
+};
+
+const LIGATURES = new FunctionRule(
+  (s1, s2) => {
+    return [replaceLigatures(s1), replaceLigatures(s2)];
+  },
+  { name: 'ligatures replaced' }
+);
+
+const DECOMPOSED_ACCENTS = ['́', '̀'];
+
+const isAccentedCharacter = char => {
+  const normalized = normalize(char, 'NFD').split('');
+  return normalized.slice(1).some(char => DECOMPOSED_ACCENTS.includes(char));
+};
path: lib/rules-fr.js
user: login: sandinmyjoints
body: Yes -- there is some similar stuff going on around [here](https://github.com/sandinmyjoints/equivalency/blob/CMCAR-29-equivalency-add-support-for-french-equivalency/lib/rules.js#L213), but not quite the same as what you're doing. 
created_at: 2025-04-14T20:23:44Z
author_association: OWNER

PR comments:
user: login: baldwinjj
created_at: 2025-04-14T20:35:10Z
body: @sandinmyjoints thanks for the review!

In answer to your question, this is the use case from the product team:
- If the accented vowel is capitalized, the accent is **not required.** But, if the user omits the accent for one capitalized vowel, it must be omitted from ALL capitalized vowels in that verb. And if the user includes the accent in one capitalized vowel, then the accent must be included in all capitalized vowels for that specific verb. For example:
    - Both Elève and Élève should be accepted as correct.
    - Both ELEVE and ÉLÈVE should be accepted.
    - ÉLEVE or ELÈVE should not be accepted because of the inconsistency.
author_association: COLLABORATOR
user: login: sandinmyjoints
created_at: 2025-04-14T20:48:24Z
body: Ah, interesting!
author_association: OWNER