Files
simple-react-app-kit/packages/i18n/src/cli/catalog-service.ts
T
Maofeng 0768e8fa29 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
2026-07-30 14:47:42 +08:00

295 lines
7.7 KiB
TypeScript

import { readFile } from "node:fs/promises"
import { dirname, join, resolve } from "node:path"
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
}
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) => ({
catalog,
key: catalog.name ?? `catalog-${index + 1}`,
}))
}
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
}): DevtoolMessage {
const source =
sourceEntry?.message ??
sourceEntry?.translation ??
(sourceLocale === locale ? targetEntry?.message : undefined) ??
id
const catalogTranslation = targetEntry?.translation ?? ""
const translation =
locale === sourceLocale && catalogTranslation.length === 0
? source
: catalogTranslation
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 context: Promise<{
catalogs: readonly CatalogEntry[]
config: Awaited<ReturnType<typeof loadProjectLinguiConfig>>
packageNameResolver: PackageNameResolver
}>
constructor(projectFilename: string) {
const packageNameResolver = new PackageNameResolver(
getProjectRoot(projectFilename)
)
this.context = loadProjectLinguiConfig(projectFilename).then(
async (config) => ({
catalogs: await getCatalogEntries(config),
config,
packageNameResolver,
})
)
}
async getMessages(locale: string) {
const { catalogs, config, packageNameResolver } = await this.context
this.assertLocale(config.locales, locale)
const applicationPackageName =
await packageNameResolver.getProjectPackageName()
const messages = await Promise.all(
catalogs.map(async ({ catalog, key }) => {
const [sourceCatalog = {}, targetCatalog = {}] = await Promise.all([
catalog.read(config.sourceLocale),
catalog.read(locale),
])
const ids = new Set([
...Object.keys(sourceCatalog),
...Object.keys(targetCatalog),
])
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 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 entry = catalogs.find((candidate) => candidate.key === input.catalog)
if (!entry) {
throw new Error(`Unknown catalog: ${input.catalog}`)
}
const [sourceCatalog = {}, targetCatalog = {}] = await Promise.all([
entry.catalog.read(config.sourceLocale),
entry.catalog.read(input.locale),
])
const sourceEntry = sourceCatalog[input.id]
const currentEntry = targetCatalog[input.id]
if (!sourceEntry && !currentEntry) {
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]: {
...sourceEntry,
...currentEntry,
translation: input.translation,
},
}
await entry.catalog.write(input.locale, nextCatalog)
return toDevtoolMessage({
applicationPackageName,
catalog: entry.key,
id: input.id,
locale: input.locale,
packageName,
sourceEntry,
sourceLocale: config.sourceLocale,
targetEntry: nextCatalog[input.id],
})
}
private assertLocale(locales: readonly string[], locale: string) {
if (!locales.includes(locale)) {
throw new Error(`Unknown locale: ${locale}`)
}
}
}