Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create @modrinth/i18n package #2161

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/app-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"dev": "vite",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write ."
},
Expand Down
6 changes: 3 additions & 3 deletions apps/app-playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
"name": "@modrinth/app-playground",
"scripts": {
"build": "cargo build --release",
"lint": "cargo fmt --check && cargo clippy -- -D warnings",
"fix": "cargo fmt && cargo clippy --fix",
"dev": "cargo run",
"test": "cargo test"
"test": "cargo test",
"lint": "cargo fmt --check && cargo clippy -- -D warnings",
"fix": "cargo fmt && cargo clippy --fix"
}
}
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
Loading