0768e8fa29
- 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
295 lines
7.7 KiB
TypeScript
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}`)
|
|
}
|
|
}
|
|
}
|