Skip to content

Commit

Permalink
Create @modrinth/i18n package
Browse files Browse the repository at this point in the history
@modrinth/ui will be a package that contains the common messages that
are used by two or more packages, as well as utilities for working with
localisation, such as `defineMessage` macro and locale files resolver
abstraction.

It is pre-built in a manner that allows for harsh tree-shaking, meaning
the messages that will not be used, should not be included by the
bundlers.

All messages are also defined using the `defineMessage` macro that
cleans the descriptors and pre-parses `defaultMessage` to AST, which
serves both as validation, as well as allows its use as fallback even in
environments devoid of bundled-in compiler. When all other code parts
are switched to use this macro, this might allow to exclude messages
files for English language, potentially saving on removed duplication.

Please note that currently `defineMessages` does not include
`defaultMessage` because it is expected that consumers of `@modrinth/ui`
will include and pre-compile extracted messages file. This can be
overriden by setting `MODRINTH_I18N_DEFAULT_MESSAGE` environment
variable to `1`.
  • Loading branch information
brawaru committed Aug 15, 2024
1 parent 7d8ba50 commit f6e9776
Show file tree
Hide file tree
Showing 14 changed files with 1,509 additions and 135 deletions.
73 changes: 38 additions & 35 deletions apps/frontend/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { resolve, basename, relative } from "pathe";
import { defineNuxtConfig } from "nuxt/config";
import { $fetch } from "ofetch";
import { globIterate } from "glob";
import { match as matchLocale } from "@formatjs/intl-localematcher";
import { consola } from "consola";
import { createLocaleResolver } from "@modrinth/i18n/utils";

const STAGING_API_URL = "https://staging-api.modrinth.com/v2/";

Expand Down Expand Up @@ -108,6 +108,9 @@ export default defineNuxtConfig({
},
}),
],
optimizeDeps: {
exclude: ["@modrinth/i18n"],
},
},
hooks: {
async "build:before"() {
Expand Down Expand Up @@ -211,53 +214,51 @@ export default defineNuxtConfig({

const isProduction = getDomain() === "https://modrinth.com";

const resolveCompactNumberDataImport = await (async () => {
const compactNumberLocales: string[] = [];

const resolveCompactNumberDataImport = await createLocaleResolver(async ({ addFile }) => {
for await (const localeFile of globIterate(
"node_modules/@vintl/compact-number/dist/locale-data/*.mjs",
{ ignore: "**/*.data.mjs" },
)) {
const tag = basename(localeFile, ".mjs");
compactNumberLocales.push(tag);
addFile(tag, { from: `@vintl/compact-number/locale-data/${tag}` });
}
});

function resolveImport(tag: string) {
const matchedTag = matchLocale([tag], compactNumberLocales, "en-x-placeholder");
return matchedTag === "en-x-placeholder"
? undefined
: `@vintl/compact-number/locale-data/${matchedTag}`;
}
const resolveOmorphiaLocaleImport = await createLocaleResolver(async ({ addFile }) => {
for await (const localeDir of globIterate("node_modules/omorphia/locales/*", {
posix: true,
})) {
const tag = basename(localeDir);

return resolveImport;
})();
for await (const localeFile of globIterate(`${localeDir}/*`, { posix: true })) {
const localeFileName = basename(localeFile);

const resolveOmorphiaLocaleImport = await (async () => {
const omorphiaLocales: string[] = [];
const omorphiaLocaleSets = new Map<string, { files: { from: string }[] }>();
if (localeFileName === "index.json") {
addFile(tag, { from: pathToFileURL(localeFile).toString(), format: "default" });
} else {
console.warn(`Ignoring handling of unknown file ${localeFile}`);
}
}
}
});

for await (const localeDir of globIterate("node_modules/omorphia/locales/*", {
const resolveCommonMessagesImport = await createLocaleResolver(async ({ addFile }) => {
for await (const localeDir of globIterate("node_modules/@modrinth/i18n/dist/files/*", {
posix: true,
})) {
const tag = basename(localeDir);
omorphiaLocales.push(tag);

const localeFiles: { from: string; format?: string }[] = [];

omorphiaLocaleSets.set(tag, { files: localeFiles });
for await (const localeFile of globIterate(`${localeDir}/*.json`, { posix: true })) {
const localeFileName = basename(localeFile);

for await (const localeFile of globIterate(`${localeDir}/*`, { posix: true })) {
localeFiles.push({
from: pathToFileURL(localeFile).toString(),
format: "default",
});
if (localeFileName === "index.json") {
addFile(tag, { from: pathToFileURL(localeFile).toString(), format: "crowdin" });
} else {
console.warn(`Ignoring handling of unknown file ${localeFile}`);
}
}
}

return function resolveLocaleImport(tag: string) {
return omorphiaLocaleSets.get(matchLocale([tag], omorphiaLocales, "en-x-placeholder"));
};
})();
});

for await (const localeDir of globIterate("src/locales/*/", { posix: true })) {
const tag = basename(localeDir);
Expand Down Expand Up @@ -301,12 +302,14 @@ export default defineNuxtConfig({
localeFiles.push(...omorphiaLocaleData.files);
}

const commonLocaleData = resolveCommonMessagesImport(tag);
if (commonLocaleData != null) {
localeFiles.push(...commonLocaleData.files);
}

const cnDataImport = resolveCompactNumberDataImport(tag);
if (cnDataImport != null) {
(locale.additionalImports ??= []).push({
from: cnDataImport,
resolve: false,
});
(locale.additionalImports ??= []).push(...cnDataImport.imports);
}
}
},
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@formatjs/intl-localematcher": "^0.5.4",
"@ltd/j-toml": "^1.38.0",
"@modrinth/assets": "workspace:*",
"@modrinth/i18n": "workspace:*",
"@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*",
"@vintl/vintl": "^4.4.1",
Expand Down
46 changes: 46 additions & 0 deletions packages/i18n/build.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { globSync } from 'glob'
import { defineBuildConfig } from 'unbuild'
import unimport from 'unimport/unplugin'
import macro from 'unplugin-macros/rollup'

export default defineBuildConfig({
entries: globSync(['./src/index.ts', './src/utils/index.ts', './src/common-messages/**/*.ts'], {
posix: true,
}).map((input) => ({
input,
})),
hooks: {
'rollup:options'(_ctx, options) {
if (Array.isArray(options.output)) {
options.output.forEach((o) => (o.preserveModules = true))
}
options.plugins ??= []

const customPlugins = [
unimport.rollup({
imports: [
{
from: './src/macros/define-message.ts',
name: 'defineMessage',
with: { type: 'macro' },
},
],
}),
macro(),
]

options.plugins = Array.isArray(options.plugins)
? [...customPlugins, ...options.plugins]
: [...customPlugins, macro(), options.plugins]
},
},
declaration: 'compatible',
// Disabling cleaning of dist may be not the brightest idea, but it helps
// avoiding a faulty state where files don't exist (HMR/live-reload concern).
//
// Hey, now it's your responsibility as a dev to clean the dist if you do
// major refactors which involves addition or deletion of files:
//
// `pnpm turbo --filter @modrinth/i18n clean` :)
clean: false,
})
37 changes: 37 additions & 0 deletions packages/i18n/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@modrinth/i18n",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": {
"import": "./dist/index.mjs"
},
"./utils": {
"import": "./dist/utils/index.mjs"
}
},
"files": [
"dist"
],
"scripts": {
"clean": "del-cli dist",
"build": "unbuild",
"intl:extract": "formatjs extract \"src/**/*.ts\" --out-file dist/files/en-US/index.json --format crowdin --preserve-whitespace"
},
"devDependencies": {
"@formatjs/cli": "^6.2.12",
"@formatjs/icu-messageformat-parser": "^2.7.8",
"glob": "^10.2.7",
"typescript": "^5.2.2",
"unbuild": "^2.0.0",
"unimport": "^3.10.0",
"unplugin-macros": "^0.13.1",
"del-cli": "^5.1.0"
},
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"@vintl/nuxt": "^1.9.2",
"@vintl/vintl": "^4.4.1"
}
}
9 changes: 9 additions & 0 deletions packages/i18n/src/common-messages/actions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const cancel = defineMessage({
id: 'common.actions.cancel',
defaultMessage: 'Cancel',
})

export const apply = defineMessage({
id: 'common.actions.apply',
defaultMessage: 'Apply',
})
1 change: 1 addition & 0 deletions packages/i18n/src/common-messages/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * as actions from './actions'
1 change: 1 addition & 0 deletions packages/i18n/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * as commonMessages from './common-messages'
43 changes: 43 additions & 0 deletions packages/i18n/src/macros/define-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { MessageDescriptor } from '@vintl/vintl'
import { parse, ParserOptions as MessageParserOptions } from '@formatjs/icu-messageformat-parser'

type ProperMessageDescriptor<I extends string> = Omit<MessageDescriptor<I>, 'defaultMessage'> & {
defaultMessage: string
}

const includeDefaultMessage = ['1', 'true'].includes(
process.env.MODRINTH_I18N_DEFAULT_MESSAGE?.toLowerCase() ?? 'false',
)

if (includeDefaultMessage) {
console.warn(
"[defineMessage] Default messages are now processed and included in the processed descriptor. This may significantly increase consumers' bundle sizes due to duplication.",
)
}

/**
* A macro that takes in a static descriptor and emits a JS object representing
* the same descriptor with any fields other than `id` and `defaultMessage`
* deleted. The `defaultMessage` field is also converted to AST, which serves as
* a validation that the message syntax is correct, as well as allows the
* message to be used without bringing compiler to runtime.
*
* @param param0 Message descriptor to process.
* @param opts Options for the message parser.
* @returns JS object with the processed message descriptor.
*/
export function defineMessage<I extends string>(
this: unknown,
{ id, defaultMessage }: ProperMessageDescriptor<I>,
opts?: MessageParserOptions,
): MessageDescriptor<I> {
if (defaultMessage == null) {
throw new RangeError(`[defineMessage] ${id} is missing 'defaultMessage'`)
}

if (typeof defaultMessage !== 'string') {
throw new RangeError(`[defineMessage] ${id} 'defaultMessage' must be a string`)
}

return includeDefaultMessage ? { id, defaultMessage: parse(defaultMessage, opts) } : { id }
}
64 changes: 64 additions & 0 deletions packages/i18n/src/utils/import-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { match as matchLocale, type Opts as MatchLocaleOpts } from '@formatjs/intl-localematcher'
import {
normalizeImportSource,
normalizeMessagesImportSource,
normalizeUnspecifiedImportSource,
type ImportSource,
type ImportSourceObject,
type MessagesImportSource,
type MessagesImportSourceObject,
type UnspecifiedImportSource,
type UnspecifiedImportSourceObject,
} from '@vintl/nuxt/options'

export interface CollectorContext {
addFile(locale: string, file: MessagesImportSource): void
addResource(locale: string, file: ImportSource): void
addImport(locale: string, import_: UnspecifiedImportSource): void
}

export interface ResolvableEntry {
files: MessagesImportSourceObject[]
resources: ImportSourceObject[]
imports: UnspecifiedImportSourceObject[]
}

export type Resolver = (locale: string) => ResolvableEntry | undefined

export async function createLocaleResolver(
collector: (ctx: CollectorContext) => void | Promise<void>,
): Promise<Resolver> {
const entries = new Map<string, ResolvableEntry>()

function getEntry(locale: string) {
let entry = entries.get(locale)
if (entry == null) {
entry = { files: [], resources: [], imports: [] }
entries.set(locale, entry)
}
return entry
}

const ctx: CollectorContext = {
addFile(locale, file) {
getEntry(locale).files.push(normalizeMessagesImportSource(file))
},
addResource(locale, file) {
getEntry(locale).resources.push(normalizeImportSource(file))
},
addImport(locale, import_) {
getEntry(locale).imports.push(normalizeUnspecifiedImportSource(import_))
},
}

await collector(ctx)

const availableLocales = [...entries.keys()]

return function resolveLocale(locales: string | string[], opts?: MatchLocaleOpts) {
const requestedLocales = Array.isArray(locales) ? locales : [locales]
const match = matchLocale(requestedLocales, availableLocales, 'en-x-placeholder', opts)
if (match === 'en-x-placeholder') return
return entries.get(match)!
}
}
1 change: 1 addition & 0 deletions packages/i18n/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createLocaleResolver } from './import-resolver.ts'
23 changes: 23 additions & 0 deletions packages/i18n/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "Preserve",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,

"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,

"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "unimport.d.ts"]
}
4 changes: 4 additions & 0 deletions packages/i18n/unimport.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {}
declare global {
const defineMessage: (typeof import('./src/macros/define-message.ts'))['defineMessage']
}
Loading

0 comments on commit f6e9776

Please sign in to comment.