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>[number] key: string } interface PackageManifest { name?: unknown } class PackageNameResolver { readonly #directoryCache = new Map>() readonly #projectRoot: string readonly #projectPackageName: Promise 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 { 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 { 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((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> 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 { 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}`) } } }