UNPKG

relative-time-format

Version:

A convenient Intl.RelativeTimeFormat polyfill

461 lines (405 loc) 16.3 kB
import path from 'path' import fs from 'fs-extra' import { isEqual } from 'lodash-es' // `make-plural` library converts CLDR pluralization rules into javascript code. // https://github.com/eemeli/make-plural import { Compiler } from 'make-plural-compiler' import * as Plurals from 'make-plural/plurals' // CLDR packages should be periodically updated as they release new versions. // `npm install cldr-core@latest cldr-dates-full@latest --save-dev` import plurals from 'cldr-core/supplemental/plurals.json' with { type: 'json' } // import ordinals from 'cldr-core/supplemental/ordinals.json' with { type: 'json' } import extractRelativeTimeMessages from '../source/CLDR/extractRelativeTimeMessages.js' import getLocalesListInCLDR from '../source/CLDR/getLocalesList.js' import getPluralRulesLocale from '../source/getPluralRulesLocale.js' // CLDR replaces missing translations with an English stub. // This can be used to find out whether a translation is missing. const LONG_STYLE_TRANSLATION_STUB = `{ "year": { "previous": "last year", "current": "this year", "next": "next year", "past": "-{0} y", "future": "+{0} y" }` // Additional locales that're not present in CLDR data. const OTHER_LOCALES = [ // Esperanto. // https://gitlab.com/catamphetamine/relative-time-format/-/merge_requests/1 'eo' ] // "Plural rules" functions are not stored in JSON files because they're not strings // therefore all of them are stored in a separate `locale/PluralRuleFunctions.js` file. // This file isn't big — it's about 5 kilobytes in size (minified). // Alternatively, the pluralization rules for each locale could be stored // in their JSON files in a non-parsed form and later parsed via `make-plural` library. // But `make-plural` library itself is relatively big in size: // `make-plural.min.js` is about 6 kilobytes (https://unpkg.com/make-plural/). // So, it's more practical to bypass runtime `make-plural` pluralization rules compilation // and just include the already compiled pluarlization rules for all locales in the library code. const pluralRuleFunctions = {} const pluralRuleFunctionAliases = {} // Load "plurals". Compiler.load( plurals, // Ordinals aren't needed for relative date/time formatting // ordinals ) // const localesListInMakePlurals = Object.keys(Plurals) const ALL_LOCALES = getLocalesListInCLDR() for (const locale of ALL_LOCALES) { writeLocaleData(locale, { pluralRuleFunctions, pluralRuleFunctionAliases }) } for (const locale of OTHER_LOCALES) { if (ALL_LOCALES[locale]) { throw new Error(`Locale "${locale}" is already present in CLDR data. Remove it from "OTHER_LOCALES" list.`); } const localeMessages = { short: readJsonFromFile(path.resolve(`./locale-other/${locale}/short.json`)), narrow: readJsonFromFile(path.resolve(`./locale-other/${locale}/narrow.json`)), long: readJsonFromFile(path.resolve(`./locale-other/${locale}/long.json`)) } writeLocaleFiles(locale, localeMessages) } addLocaleExports(ALL_LOCALES) // Output "plural rules" functions for all locales. writePluralRulesFunctions({ pluralRuleFunctions, pluralRuleFunctionAliases }) // // Remove strange locales. // removeLocaleBundle('en-001') // removeLocaleBundle('en-150') // All locales supported by `make-plurals` are languages // except for the "pt-PT" locale which is a weird one // with Portuguese pluralization rules being different // for Europe (0 dia, 1.5 dia) and non-Europe (0 dias, 1.5 dias). function getPluralRuleFunctionCode(locale) { if (Plurals[locale]) { const expectedLocale = getPluralRulesLocale(locale) if (locale !== expectedLocale) { throw new Error(`Expected to find pluralization rules for "${expectedLocale}" locale but found them for "${locale}" locale`) } return { locale, quantify: new Compiler(locale).compile().toString() } } const parts = locale.split('-') parts.pop() locale = parts.join('-') if (locale) { return getPluralRuleFunctionCode(locale) } // Not found. return {} } /** * CLDR data always has relative time messages duplicated * for all keys if they're the same. * For example, if relative time messages are the same for * "one", "many" and "other" they will still be duplicated * in CLDR data files. * This function removes such duplication to reduce the * resulting bundle size. * Mutates the object passed as the argument directly. * @param {object} flavor — Relative time messages of a given flavor. */ function compactPluralRulesData(flavour) { for (const unit of Object.keys(flavour)) { for (const pastOrFuture of Object.keys(flavour[unit])) { for (const quantifier of Object.keys(flavour[unit][pastOrFuture])) { // "other" is the one holding the relative time message in case of duplication. if (quantifier === 'other') { continue } if (flavour[unit][pastOrFuture][quantifier] === flavour[unit][pastOrFuture].other) { delete flavour[unit][pastOrFuture][quantifier] } } } } } // /** // * Removes locale data bundle file. // * @param {string} locale // */ // function removeLocaleBundle(locale) { // fs.removeSync(path.resolve(`./locale/${locale}.json`)) // } /** * Finds a parent locale whose messages of a given style are equal to that of the specified locale. * @param {string} locale * @return {string} [parentLocale] */ function findParentLocaleBundleHavingSameLabelsOfStyle(locale, messages, style) { const parts = locale.split('-') let length = 1 while (length < parts.length) { // `sr-Cyrl-BA` -> `sr` -> `sr-Cyrl`. const parentLocale = parts.slice(0, length).join('-') if (fs.existsSync(path.resolve(`./locale/${parentLocale}.json`))) { // Compare parent locale messages to those of the given locale. // const messages = readJsonFromFile(path.resolve(`./locale/${locale}.json`)) const parentLocaleMessages = readJsonFromFile(path.resolve(`./locale/${parentLocale}.json`)) if (isEqual(parentLocaleMessages[style], messages[style])) { return parentLocale } } length++ } } function stringifyMessages(messages) { return JSON.stringify(messages, null, '\t') .split('\n') .map((_, i) => i === 0 ? _ : '\t' + _) .join('\n') } function tabulate(code, count) { return code.split('\n').map(_ => '\t'.repeat(count) + convertSpacesToTabs(_)).join('\n') } function convertSpacesToTabs(text) { let spacesToTabs while ((spacesToTabs = text.replace(/^(\t*) /, '$1\t')) !== text) { text = spacesToTabs } return text } /** * Returns a given "style" (short, long, narrow) labels if the file exists. * @param {string} locale * @param {string} style * @return {object} [json] */ function findStyle(locale, style) { if (fs.existsSync(path.join('./locale-more-styles', locale, `${style}.json`))) { return readJsonFromFile(path.join('./locale-more-styles', locale, `${style}.json`)) } } function readJsonFromFile(path) { return JSON.parse(fs.readFileSync(path, 'utf8')) } function writeLocaleData(locale, { pluralRuleFunctions, pluralRuleFunctionAliases }) { // "pt" language is weird because there're the regular "pt" (Portuguese), // the "pt-PT" (European Portuguese) and various "pt-XX"s. // // For "pt" language the relative time messages seem to be different // from all other "pt-" variations which seem to be identical to "pt-PT". // // Also, "plural rules" function for "pt" language is different from "pt-PT". // It's the only case when there's a "plural rules" function for a locale // instead of a language. // http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html // (See "pt" vs "pt_PT" there) // For English there're different variations of "en" language // which differ from one another by a simple dot, e.g. "yr." vs "yr". const cldrJsonPath = `./node_modules/cldr-dates-full/main/${locale}/dateFields.json` const localeMessages = extractRelativeTimeMessages(readJsonFromFile(cldrJsonPath)) // "long" messages are always present. if (!localeMessages.long) { throw new Error(`Default (long) locale data is missing for locale "${locale}".`) } // If there are no translations for a locale then skip it. if (JSON.stringify(localeMessages.long, null, '\t').indexOf(LONG_STYLE_TRANSLATION_STUB) === 0) { console.log(`Locale "${locale}" doesn't have translated messages. Skipping.`) return } // "short" messages are always present. if (!localeMessages.short) { throw new Error(`Short locale data is missing for locale "${locale}".`) } // "narrow" messages are always present. if (!localeMessages.narrow) { throw new Error(`Narrow locale data is missing for locale "${locale}".`) } // Drop duplicate pluralized messages. compactPluralRulesData(localeMessages.long) compactPluralRulesData(localeMessages.short) compactPluralRulesData(localeMessages.narrow) // An explanation of what are "narrow" and "short" styles and how are they constructed: // http://cldr.unicode.org/translation/plurals#TOC-Narrow-and-Short-Forms const longMessagesSameAsFor = findParentLocaleBundleHavingSameLabelsOfStyle(locale, localeMessages, 'long') const shortMessagesSameAsFor = findParentLocaleBundleHavingSameLabelsOfStyle(locale, localeMessages, 'short') const narrowMessagesSameAsFor = findParentLocaleBundleHavingSameLabelsOfStyle(locale, localeMessages, 'narrow') // If this locale fully inherits from a parent locale then skip it. if (longMessagesSameAsFor && longMessagesSameAsFor === shortMessagesSameAsFor && longMessagesSameAsFor === narrowMessagesSameAsFor) { console.log(`Locale "${locale}" fully inherits from "${longMessagesSameAsFor}". Skipping.`) return } // Get "plural rules" function code. const { quantify, locale: quantifyLocale } = getPluralRuleFunctionCode(locale) // If "plural rules" function is defined for this locale then add it to `PluralRuleFunctions.js`. if (quantify && !pluralRuleFunctions[quantifyLocale]) { // If this "plural rules" function is a duplicate of an already existing one // then don't add its code to the list. for (const locale of Object.keys(pluralRuleFunctions)) { if (pluralRuleFunctions[locale] === quantify) { // Just alias it. pluralRuleFunctionAliases[quantifyLocale] = locale } } // If this "plural rules" function is not a duplicate than add its code to the list. if (!pluralRuleFunctionAliases[quantifyLocale]) { pluralRuleFunctions[quantifyLocale] = quantify } } writeLocaleFiles(locale, localeMessages) } function writeLocaleFiles(locale, localeMessages) { const styles = ['long', 'short', 'narrow'] for (const style of styles) { // Validate that messages are present for this style for the locale. if (!localeMessages[style]) { throw new Error(`"${style}" messages not found for locale "${locale}"`); } // Create `${locale}/${style}.json` file in the "locales/${locale}" directory. fs.outputFileSync( path.join('./locale', locale, `${style}.json`), JSON.stringify({ locale, style, ...localeMessages[style] }, null, '\t') ) // Create `${locale}/${style}.json.js` file in the "locales/${locale}" directory. // // Stupid Node.js can't even `import` JSON files. // https://stackoverflow.com/questions/72348042/typeerror-err-unknown-file-extension-unknown-file-extension-json-for-node // Using a `*.json.js` duplicate file workaround. // fs.outputFileSync( path.join('./locale', locale, `${style}.json.js`), 'export default ' + JSON.stringify({ locale, style, ...localeMessages[style] }, null, '\t') ) // Create `${locale}/${style}.json.d.ts` file in the "locales/${locale}" directory. fs.outputFileSync( path.join('./locale', locale, `${style}.json.d.ts`), ` import { Labels } from '../../index'; type LocaleLabelsForStyle = Labels & { locale: '${locale}'; style: '${style}'; }; declare const localeLabels: LocaleLabelsForStyle; export default localeLabels; `.trim() ) } // Create `${locale}.json` file in the "locales" directory. fs.outputFileSync( path.join('./locale', `${locale}.json`), JSON.stringify({ locale, ...localeMessages }, null, '\t') ) // Create `${locale}.json.js` file in the "locales" directory. // // Stupid Node.js can't even `import` JSON files. // https://stackoverflow.com/questions/72348042/typeerror-err-unknown-file-extension-unknown-file-extension-json-for-node // Using a `*.json.js` duplicate file workaround. // fs.outputFileSync( path.join('./locale', `${locale}.json.js`), 'export default ' + JSON.stringify({ locale, ...localeMessages }, null, '\t') ) // Create `${locale}.json.d.ts` file in the "locales" directory. fs.outputFileSync( path.join('./locale', `${locale}.json.d.ts`), ` import { LocaleData } from '../index'; declare const localeData: LocaleData; export default localeData; `.trim() ) // Create `${locale}/package.json` file in the "locales/${locale}" directory. fs.outputFileSync( path.join('./locale', locale, 'package.json'), JSON.stringify({ private: true, name: `relative-time-format/locale/${locale}`, main: `../${locale}.json`, module: `../${locale}.json.js`, types: `../${locale}.json.d.ts`, type: 'module', exports: { '.': { types: `../${locale}.json.d.ts`, import: `../${locale}.json.js`, require: `../${locale}.json` } }, sideEffects: false }, null, '\t') ) } // Outputs "plural rules" functions for all locales. function writePluralRulesFunctions({ pluralRuleFunctions, pluralRuleFunctionAliases }) { fs.outputFileSync( path.resolve('./source/PluralRuleFunctions.js'), '// (this file was autogenerated by `generate-locales`)' + '\n' + '// "plural rules" functions are not stored in locale JSON files because they\'re not strings.' + '\n' + '// This file isn\'t big — it\'s about 5 kilobytes in size (minified).' + '\n' + '// Alternatively, the pluralization rules for each locale could be stored' + '\n' + '// in their JSON files in a non-parsed form and later parsed via `make-plural` library.' + '\n' + '// But `make-plural` library itself is relatively big in size:' + '\n' + '// `make-plural.min.js` is about 6 kilobytes (https://unpkg.com/make-plural/).' + '\n' + '// So, it\'s more practical to bypass runtime `make-plural` pluralization rules compilation' + '\n' + '// and just include the already compiled pluarlization rules for all locales in the library code.' + '\n' + '\n' + 'var $ = {' + '\n' + Object.keys(pluralRuleFunctions).map(locale => `\t${locale}: ${tabulate(pluralRuleFunctions[locale], 1).slice(1)}`).join(',\n') + '\n}' + '\n\n' + Object.keys(pluralRuleFunctionAliases).map(locale => `$${locale.indexOf('-') > 0 ? '["' + locale + '"]' : '.' + locale} = $.${pluralRuleFunctionAliases[locale]}`).join('\n') + '\n\n' + 'export default $' ) } // Add `export` entries in `package.json`. function addLocaleExports(ALL_LOCALES) { // Read `package.json` file. const packageJson = readJsonFromFile('./package.json') // Remove all locale exports. for (const path of Object.keys(packageJson.exports)) { if (path.startsWith('./locale/')) { delete packageJson.exports[path] } } // Re-add all locale exports. packageJson.exports = { ...packageJson.exports, ...ALL_LOCALES.reduce((all, locale) => { all[`./locale/${locale}.json`] = { types: `./locale/${locale}.json.d.ts`, import: `./locale/${locale}.json.js`, require: `./locale/${locale}.json` } all[`./locale/${locale}`] = { types: `./locale/${locale}.json.d.ts`, import: `./locale/${locale}.json.js`, require: `./locale/${locale}.json` } return all }, {}) } // Save `package.json` file. fs.writeFileSync('./package.json', JSON.stringify(packageJson, null, 2) + '\n', 'utf8') } function getStyleVariableName(style) { return style.replace(/[a-zA-Z_\d]/g, '_') }