feat(i18n): add Lingui runtime and development tooling
- add SSR-safe I18nProvider, Translate, locale hooks, catalog activation, and typed runtime APIs - add consumer-owned Lingui configuration helpers and a CLI for locale creation, extraction, compilation, and project discovery - add catalog read/write services plus a Vite development API plugin scoped to each consuming application - add the portal-based I18nDevtool, message editor, dark-theme tracking, and standalone Message Studio - document the consumer workflow and cover runtime, configuration, catalog, CLI, and Devtool behavior with tests
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { getCatalogs } from "@lingui/cli/api"
|
||||
import type { CatalogType, MessageType } from "@lingui/conf"
|
||||
import { getConfig } from "@lingui/conf"
|
||||
|
||||
import type { DevtoolMessage, UpdateMessageInput } from "../devtool/types"
|
||||
import { getLinguiConfigFilename, getProjectRoot } from "./project.ts"
|
||||
|
||||
interface CatalogEntry {
|
||||
catalog: Awaited<ReturnType<typeof getCatalogs>>[number]
|
||||
key: string
|
||||
}
|
||||
|
||||
async function getCatalogEntries(config: ReturnType<typeof getConfig>) {
|
||||
const catalogs = await getCatalogs(config)
|
||||
|
||||
return catalogs.map<CatalogEntry>((catalog, index) => ({
|
||||
catalog,
|
||||
key: catalog.name ?? `catalog-${index + 1}`,
|
||||
}))
|
||||
}
|
||||
|
||||
function toDevtoolMessage({
|
||||
catalog,
|
||||
id,
|
||||
locale,
|
||||
sourceEntry,
|
||||
sourceLocale,
|
||||
targetEntry,
|
||||
}: {
|
||||
catalog: string
|
||||
id: string
|
||||
locale: 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 ?? [],
|
||||
id,
|
||||
missing: locale !== sourceLocale && translation.length === 0,
|
||||
obsolete: Boolean(targetEntry?.obsolete ?? sourceEntry?.obsolete),
|
||||
origins: (sourceEntry?.origin ?? targetEntry?.origin ?? []).map(
|
||||
([file, line]) => ({ file, line })
|
||||
),
|
||||
source,
|
||||
translation,
|
||||
}
|
||||
}
|
||||
|
||||
export class CatalogService {
|
||||
readonly config: ReturnType<typeof getConfig>
|
||||
readonly catalogs: Promise<readonly CatalogEntry[]>
|
||||
|
||||
constructor(projectFilename: string) {
|
||||
this.config = getConfig({
|
||||
configPath: getLinguiConfigFilename(projectFilename),
|
||||
cwd: getProjectRoot(projectFilename),
|
||||
})
|
||||
this.catalogs = getCatalogEntries(this.config)
|
||||
}
|
||||
|
||||
async getMessages(locale: string) {
|
||||
this.assertLocale(locale)
|
||||
|
||||
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(locale),
|
||||
])
|
||||
const ids = new Set([
|
||||
...Object.keys(sourceCatalog),
|
||||
...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 messages.flat()
|
||||
}
|
||||
|
||||
async updateMessage(input: UpdateMessageInput) {
|
||||
this.assertLocale(input.locale)
|
||||
|
||||
const catalogs = await this.catalogs
|
||||
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(this.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 nextCatalog: CatalogType = {
|
||||
...targetCatalog,
|
||||
[input.id]: {
|
||||
...sourceEntry,
|
||||
...currentEntry,
|
||||
translation: input.translation,
|
||||
},
|
||||
}
|
||||
|
||||
await entry.catalog.write(input.locale, nextCatalog)
|
||||
|
||||
return toDevtoolMessage({
|
||||
catalog: entry.key,
|
||||
id: input.id,
|
||||
locale: input.locale,
|
||||
sourceEntry,
|
||||
sourceLocale: this.config.sourceLocale,
|
||||
targetEntry: nextCatalog[input.id],
|
||||
})
|
||||
}
|
||||
|
||||
private assertLocale(locale: string) {
|
||||
if (!this.config.locales.includes(locale)) {
|
||||
throw new Error(`Unknown locale: ${locale}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user