Skip to content
6 changes: 6 additions & 0 deletions .changeset/huge-brooms-judge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@medusajs/types": minor
"@medusajs/utils": minor
---

fix(types,utils): pluralization for compound words ending in uncountable word
219 changes: 122 additions & 97 deletions packages/core/types/src/common/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,106 +255,116 @@ export interface NumericalComparisonOperator {
lte?: number
}

/**
* Shared source of truth for uncountable words.
*
* These words should remain uncountable even when used in compound words.
* For example: "CountryCompanyInfo" -> "CountryCompanyInfo" (not "CountryCompanyInfoes")
*
*/
export const UNCOUNTABLE_WORDS = [
"adulthood",
"advice",
"agenda",
"aid",
"aircraft",
"alcohol",
"ammo",
"analytics",
"anime",
"athletics",
"audio",
"bison",
"blood",
"bream",
"buffalo",
"butter",
"carp",
"cash",
"chassis",
"chess",
"clothing",
"cod",
"commerce",
"cooperation",
"corps",
"debris",
"diabetes",
"digestion",
"elk",
"energy",
"equipment",
"excretion",
"expertise",
"firmware",
"flounder",
"fun",
"gallows",
"garbage",
"graffiti",
"hardware",
"headquarters",
"health",
"herpes",
"highjinks",
"homework",
"housework",
"information",
"jeans",
"justice",
"kudos",
"labour",
"literature",
"machinery",
"mackerel",
"mail",
"media",
"mews",
"moose",
"music",
"mud",
"manga",
"news",
"only",
"personnel",
"pike",
"plankton",
"pliers",
"police",
"pollution",
"premises",
"rain",
"research",
"rice",
"salmon",
"scissors",
"series",
"sewage",
"shambles",
"shrimp",
"software",
"staff",
"swine",
"tennis",
"traffic",
"transportation",
"trout",
"tuna",
"wealth",
"welfare",
"whiting",
"wildebeest",
"wildlife",
"you",
"deer",
"sheep",
"info",
] as const

/**
* The keywords that does not have a plural form
*/
type UncountableRules =
| "adulthood"
| "advice"
| "agenda"
| "aid"
| "aircraft"
| "alcohol"
| "ammo"
| "analytics"
| "anime"
| "athletics"
| "audio"
| "bison"
| "blood"
| "bream"
| "buffalo"
| "butter"
| "carp"
| "cash"
| "chassis"
| "chess"
| "clothing"
| "cod"
| "commerce"
| "cooperation"
| "corps"
| "debris"
| "diabetes"
| "digestion"
| "elk"
| "energy"
| "equipment"
| "excretion"
| "expertise"
| "firmware"
| "flounder"
| "fun"
| "gallows"
| "garbage"
| "graffiti"
| "hardware"
| "headquarters"
| "health"
| "herpes"
| "highjinks"
| "homework"
| "housework"
| "information"
| "jeans"
| "justice"
| "kudos"
| "labour"
| "literature"
| "machinery"
| "mackerel"
| "mail"
| "media"
| "mews"
| "moose"
| "music"
| "mud"
| "manga"
| "news"
| "only"
| "personnel"
| "pike"
| "plankton"
| "pliers"
| "police"
| "pollution"
| "premises"
| "rain"
| "research"
| "rice"
| "salmon"
| "scissors"
| "series"
| "sewage"
| "shambles"
| "shrimp"
| "software"
| "staff"
| "swine"
| "tennis"
| "traffic"
| "transportation"
| "trout"
| "tuna"
| "wealth"
| "welfare"
| "whiting"
| "wildebeest"
| "wildlife"
| "you"
| "deer"
| "sheep"
| "info"
type UncountableRules = (typeof UNCOUNTABLE_WORDS)[number]

type PluralizationSpecialRules = {
person: "people"
Expand All @@ -365,6 +375,19 @@ type PluralizationSpecialRules = {
foot: "feet"
}

/**
* Helper type to check if a word ends with any uncountable word.
* This handles compound words ending with uncountable nouns, keeping them
* uncountable. For example:
* - "CountryCompanyInfo" -> "CountryCompanyInfo" (not "CountryCompanyInfoes")
* - "UserData" -> "UserData" (not "UserDatas")
* - "SocialMedia" -> "SocialMedia" (not "SocialMedias")
*
* @ignore
*/
type EndsWithUncountable<S extends string> =
Lowercase<S> extends `${string}${UncountableRules}` ? true : false

/**
* @ignore
*/
Expand All @@ -373,6 +396,8 @@ export type Pluralize<Singular extends string> =
? PluralizationSpecialRules[Lowercase<Singular>]
: Lowercase<Singular> extends UncountableRules
? Singular
: EndsWithUncountable<Singular> extends true
? Singular
: Singular extends `${string}ss`
? `${Singular}es`
: Singular extends `${infer R}sis`
Expand Down
4 changes: 4 additions & 0 deletions packages/core/utils/src/common/__tests__/pluralize.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ describe("pluralize", function () {
"potato",
"address",
"info",
"rice",
"price",
]

const expectedOutput = [
Expand All @@ -26,6 +28,8 @@ describe("pluralize", function () {
"potatoes",
"addresses",
"info",
"rice",
"prices",
]

words.forEach((word, index) => {
Expand Down
30 changes: 28 additions & 2 deletions packages/core/utils/src/common/plurailze.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,35 @@
import pluralizeEN from "pluralize"
pluralizeEN.addUncountableRule("info")
import { UNCOUNTABLE_WORDS } from "@medusajs/types"
import { upperCaseFirst } from "./upper-case-first"

/**
* Configure pluralize library with uncountable rules from shared source of truth
* both for exact words and compound words ending with uncountable words.
*
* The regex ensures that:
* 1. The word is exactly the uncountable word (case-insensitive), OR
* 2. The word is a compound word where the uncountable word appears at the end with proper word boundaries:
* - camelCase: preceded by a lowercase letter (indicating word boundary, e.g., "WildRice", "softDeleteRice")
* - snake_case: preceded by an underscore (e.g., "wild_rice", "country_company_info")
*
* This prevents false matches like "price" matching "rice" or "softDeletePrice" matching "rice".
*/
UNCOUNTABLE_WORDS.forEach((word) => {
// Match exact word (case-insensitive)
pluralizeEN.addUncountableRule(new RegExp(`^${word}$`, "i"))

// - Ending with uncountable word preceded by lowercase letter (word boundary for camelCase)
// e.g., "WildRice", "softDeleteRice" (but NOT "softDeletePrice")
const capitalizedWord = upperCaseFirst(word)
pluralizeEN.addUncountableRule(new RegExp(`.*[a-z]${capitalizedWord}$`))

// Ending with uncountable word preceded by underscore (word boundary for snake_case)
// e.g., "wild_rice", "country_company_info"
pluralizeEN.addUncountableRule(new RegExp(`.*_${word}$`, "i"))
})

/**
* Function to pluralize English words.
* @param word
*/
export function pluralize(word: string): string {
// TODO: Implement language specific pluralize function
Expand Down