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:
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user