feat(i18n): expand catalog runtime and developer tooling

- add Intl-backed locale definitions, formatters, catalog loaders, and runtime hooks\n- compile hashed production catalogs from package-scoped catalog sources\n- drive Lingui extraction and compilation directly from project JSON config\n- redesign the devtool with localized controls, draggable persistence, settings, navigation, copy actions, ANSI output, and fullscreen support\n- extend repository APIs, CLI coverage, documentation, and runtime tests
This commit is contained in:
Maofeng
2026-07-30 14:47:42 +08:00
parent 0d0aad1d47
commit 0768e8fa29
55 changed files with 7553 additions and 495 deletions
+174 -34
View File
@@ -1,16 +1,98 @@
import { getCatalogs } from "@lingui/cli/api"
import type { CatalogType, MessageType } from "@lingui/conf"
import { getConfig } from "@lingui/conf"
import { readFile } from "node:fs/promises"
import { dirname, join, resolve } from "node:path"
import type { DevtoolMessage, UpdateMessageInput } from "../devtool/types"
import { getLinguiConfigFilename, getProjectRoot } from "./project.ts"
import { getCatalogs } from "@lingui/cli/api"
import type {
CatalogType,
LinguiConfigNormalized,
MessageType,
} from "@lingui/conf"
import type {
DevtoolMessage,
DevtoolMessagePackage,
UpdateMessageInput,
} from "../devtool/types"
import { getProjectRoot, loadProjectLinguiConfig } from "./project.ts"
interface CatalogEntry {
catalog: Awaited<ReturnType<typeof getCatalogs>>[number]
key: string
}
async function getCatalogEntries(config: ReturnType<typeof getConfig>) {
interface PackageManifest {
name?: unknown
}
class PackageNameResolver {
readonly #directoryCache = new Map<string, Promise<string | undefined>>()
readonly #projectRoot: string
readonly #projectPackageName: Promise<string>
constructor(projectRoot: string) {
this.#projectRoot = projectRoot
this.#projectPackageName = this.#findNearestPackageName(projectRoot).then(
(name) => name ?? "project"
)
}
async resolve(origins: MessageType["origin"] | undefined) {
for (const [filename] of origins ?? []) {
const packageName = await this.#findNearestPackageName(
dirname(resolve(this.#projectRoot, filename))
)
if (packageName) {
return packageName
}
}
return this.#projectPackageName
}
getProjectPackageName() {
return this.#projectPackageName
}
#findNearestPackageName(directory: string): Promise<string | undefined> {
const normalizedDirectory = resolve(directory)
const cached = this.#directoryCache.get(normalizedDirectory)
if (cached) {
return cached
}
const result = this.#readNearestPackageName(normalizedDirectory)
this.#directoryCache.set(normalizedDirectory, result)
return result
}
async #readNearestPackageName(
directory: string
): Promise<string | undefined> {
try {
const manifest = JSON.parse(
await readFile(join(directory, "package.json"), "utf8")
) as PackageManifest
if (typeof manifest.name === "string" && manifest.name.length > 0) {
return manifest.name
}
} catch {
// Source files may sit below directories without a package manifest.
}
const parent = dirname(directory)
if (parent === directory) {
return undefined
}
return this.#findNearestPackageName(parent)
}
}
async function getCatalogEntries(config: LinguiConfigNormalized) {
const catalogs = await getCatalogs(config)
return catalogs.map<CatalogEntry>((catalog, index) => ({
@@ -20,16 +102,20 @@ async function getCatalogEntries(config: ReturnType<typeof getConfig>) {
}
function toDevtoolMessage({
applicationPackageName,
catalog,
id,
locale,
packageName,
sourceEntry,
sourceLocale,
targetEntry,
}: {
applicationPackageName: string
catalog: string
id: string
locale: string
packageName: string
sourceEntry: MessageType | undefined
sourceLocale: string
targetEntry: MessageType | undefined
@@ -48,37 +134,52 @@ function toDevtoolMessage({
return {
catalog,
comments: targetEntry?.comments ?? sourceEntry?.comments ?? [],
editable: locale !== sourceLocale || packageName !== applicationPackageName,
id,
missing: locale !== sourceLocale && translation.length === 0,
obsolete: Boolean(targetEntry?.obsolete ?? sourceEntry?.obsolete),
origins: (sourceEntry?.origin ?? targetEntry?.origin ?? []).map(
([file, line]) => ({ file, line })
),
packageName,
source,
sourceLocale,
translation,
}
}
export class CatalogService {
readonly config: ReturnType<typeof getConfig>
readonly catalogs: Promise<readonly CatalogEntry[]>
readonly context: Promise<{
catalogs: readonly CatalogEntry[]
config: Awaited<ReturnType<typeof loadProjectLinguiConfig>>
packageNameResolver: PackageNameResolver
}>
constructor(projectFilename: string) {
this.config = getConfig({
configPath: getLinguiConfigFilename(projectFilename),
cwd: getProjectRoot(projectFilename),
})
this.catalogs = getCatalogEntries(this.config)
const packageNameResolver = new PackageNameResolver(
getProjectRoot(projectFilename)
)
this.context = loadProjectLinguiConfig(projectFilename).then(
async (config) => ({
catalogs: await getCatalogEntries(config),
config,
packageNameResolver,
})
)
}
async getMessages(locale: string) {
this.assertLocale(locale)
const { catalogs, config, packageNameResolver } = await this.context
this.assertLocale(config.locales, locale)
const applicationPackageName =
await packageNameResolver.getProjectPackageName()
const catalogs = await this.catalogs
const messages = await Promise.all(
catalogs.map(async ({ catalog, key }) => {
const [sourceCatalog = {}, targetCatalog = {}] = await Promise.all([
catalog.read(this.config.sourceLocale),
catalog.read(config.sourceLocale),
catalog.read(locale),
])
const ids = new Set([
@@ -86,28 +187,50 @@ export class CatalogService {
...Object.keys(targetCatalog),
])
return Array.from(ids)
.sort((left, right) => left.localeCompare(right))
.map((id) =>
toDevtoolMessage({
catalog: key,
id,
locale,
sourceEntry: sourceCatalog[id],
sourceLocale: this.config.sourceLocale,
targetEntry: targetCatalog[id],
return Promise.all(
Array.from(ids)
.sort((left, right) => left.localeCompare(right))
.map(async (id) => {
const sourceEntry = sourceCatalog[id]
const targetEntry = targetCatalog[id]
const packageName = await packageNameResolver.resolve(
sourceEntry?.origin ?? targetEntry?.origin
)
return toDevtoolMessage({
applicationPackageName,
catalog: key,
id,
locale,
packageName,
sourceEntry,
sourceLocale: config.sourceLocale,
targetEntry,
})
})
)
)
})
)
return messages.flat()
}
async updateMessage(input: UpdateMessageInput) {
this.assertLocale(input.locale)
async getPackages(): Promise<readonly DevtoolMessagePackage[]> {
const { packageNameResolver } = await this.context
return [
{
kind: "application",
name: await packageNameResolver.getProjectPackageName(),
},
]
}
async updateMessage(input: UpdateMessageInput) {
const { catalogs, config, packageNameResolver } = await this.context
this.assertLocale(config.locales, input.locale)
const catalogs = await this.catalogs
const entry = catalogs.find((candidate) => candidate.key === input.catalog)
if (!entry) {
@@ -115,7 +238,7 @@ export class CatalogService {
}
const [sourceCatalog = {}, targetCatalog = {}] = await Promise.all([
entry.catalog.read(this.config.sourceLocale),
entry.catalog.read(config.sourceLocale),
entry.catalog.read(input.locale),
])
const sourceEntry = sourceCatalog[input.id]
@@ -125,6 +248,21 @@ export class CatalogService {
throw new Error(`Unknown message: ${input.id}`)
}
const packageName = await packageNameResolver.resolve(
sourceEntry?.origin ?? currentEntry?.origin
)
const applicationPackageName =
await packageNameResolver.getProjectPackageName()
if (
input.locale === config.sourceLocale &&
packageName === applicationPackageName
) {
throw new Error(
"Application source messages must be changed in source code."
)
}
const nextCatalog: CatalogType = {
...targetCatalog,
[input.id]: {
@@ -137,17 +275,19 @@ export class CatalogService {
await entry.catalog.write(input.locale, nextCatalog)
return toDevtoolMessage({
applicationPackageName,
catalog: entry.key,
id: input.id,
locale: input.locale,
packageName,
sourceEntry,
sourceLocale: this.config.sourceLocale,
sourceLocale: config.sourceLocale,
targetEntry: nextCatalog[input.id],
})
}
private assertLocale(locale: string) {
if (!this.config.locales.includes(locale)) {
private assertLocale(locales: readonly string[], locale: string) {
if (!locales.includes(locale)) {
throw new Error(`Unknown locale: ${locale}`)
}
}