Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 46 additions & 17 deletions docs/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,23 +75,52 @@ JSON inválido interrompem a geração. Nenhum fallback silencioso cobre traduç
esquecidas. Os diretórios gerados são ignorados pelo Git; edite somente os
catálogos colocalizados.

`vp run i18n:generate` executa essa verificação isoladamente. Desenvolvimento e
build chamam o mesmo gerador antes de continuar; o watcher bloqueia respostas
quando um catálogo fica inválido e volta a servir após a correção. Agregados e
módulos gerados não disparam outra geração; conteúdo idêntico não é regravado
nem provoca reload. Os scripts de typecheck, `check:ci` e testes geram as
mensagens antes da validação, inclusive num clone novo. O plugin de formato vem
da dependência local, sem baixar código de CDN durante a geração.

As opções do compilador ficam em `project.inlang/paraglide.config.ts`. O gerador
valida e agrega os catálogos, então executa o CLI oficial do Paraglide. As duas
etapas usam Bun, inclusive a emissão das declarações de tipos. O próprio
compilador reaproveita os hashes da saída, escreve as mudanças e remove módulos
obsoletos de `paraglide/`. Esses arquivos gerados dispensam lixeira. A fila e a
comparação de conteúdo evitam gerações e reloads repetidos no mesmo processo; a
tarefa do Vite+ acrescenta cache entre execuções. O custo fica na preparação do
desenvolvimento/build e nas verificações, sem executar o gerador nas requisições
de produção.
`vp run i18n:generate` prepara os agregados e executa o CLI oficial do
Paraglide. No desenvolvimento e no build, `i18nPlugin` prepara os mesmos
agregados e entrega a compilação ao `paraglideVitePlugin` oficial. O plugin
mantém o cache, acompanha os arquivos usados pelo compilador e publica as
atualizações pelo HMR do Vite. O adaptador local cuida da descoberta e validação
dos catálogos colocalizados. Catálogos ou configurações inválidos bloqueiam
respostas no desenvolvimento; após a correção, o servidor volta a servir. Um
navegador que abriu a página de erro em texto precisa recarregá-la; clientes já
conectados recebem o overlay e a recuperação pelo HMR.

O adaptador agrupa os eventos dos catálogos e de `project.inlang/settings.json`
antes de preparar as mensagens: espera 100 ms e renova a espera quando chegam
novos eventos relevantes. O hook `watchChange` sequencial faz o plugin oficial
aguardar essa preparação antes de processar o mesmo evento. As opções de
`server.watch` ficam sob controle do projeto; o i18n preserva o padrão, opções
personalizadas e `watch: null`. Alterações de código e CSS seguem sem essa
espera.

Essa janela agrupa eventos próximos, sem garantir que uma escrita terminou:
partes separadas por mais tempo ainda podem expor um arquivo incompleto.
Agregados e módulos gerados não disparam outra preparação; conteúdo idêntico não
é regravado. Ao fechar o servidor, preparações e compilações já iniciadas
terminam. Eventos que o watcher ainda não entregou ficam para a próxima
inicialização, que valida os catálogos novamente e atualiza a saída. Os scripts
de typecheck, `check:ci` e testes geram as mensagens antes da validação,
inclusive num clone novo. O plugin de formato vem da dependência local, sem
baixar código de CDN durante a geração.

As opções do compilador ficam em `project.inlang/paraglide.config.ts`. O CLI
avulso e os comandos de desenvolvimento/build usam Bun. O próprio compilador
reaproveita os hashes da saída, escreve as mudanças e remove módulos obsoletos
de `paraglide/`. Esses arquivos gerados dispensam lixeira. A tarefa do Vite+
acrescenta cache ao comando avulso. A geração dos snapshots Markdown aguarda a
compilação inicial e acompanha os módulos gerados para atualizar os textos
traduzidos. Nenhuma dessas etapas executa nas requisições de produção.

O plugin publicado já inclui as mudanças do
[PR upstream #820](https://github.com/opral/paraglide-js/pull/820): a opção
`devErrorResponse` do Vite, a espera pela compilação pendente ao encerrar o
servidor e a invalidação dos caches dessa fila quando ela termina durante o
encerramento. Isso força a próxima inicialização a compilar novamente quando uma
segunda gravação chega durante a leitura anterior e seu evento é descartado no
encerramento. A dependência usa essas implementações diretamente. A validação
estrita pelo compilador está proposta separadamente no
[PR upstream #794](https://github.com/opral/paraglide-js/pull/794); até a
publicação, ela continua no adaptador com `checkBundle` do SDK oficial.

O bundler elimina mensagens não usadas e mantém as mensagens exclusivas da
página no seu módulo lazy. Cada módulo contém os três idiomas das mensagens que
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"filesystem-routing": "0.4.0",
"solid-js": "2.0.0-rc.15",
"valibot": "1.5.0",
"@inlang/paraglide-js": "2.28.0"
"@inlang/paraglide-js": "2.29.0"
},
"devDependencies": {
"@commitlint/cli": "21.2.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,20 @@ describe('generated messages', () => {
parent: await sandbox(context),
messages: MESSAGES
})
assert.deepEqual(await generateMessages(root), { changed: true })
await generateMessages(root)
const reference = await output(root)
const times = await modifiedTimes(root)
assert.ok(reference.has('messages/home_title.js'))
assert.ok(reference.has('messages/home_title.d.ts'))

assert.deepEqual(await generateMessages(root), { changed: false })
await generateMessages(root)
assert.deepEqual(await modifiedTimes(root), times)

await execute('bun', [GENERATE], { cwd: root })
assert.deepEqual(await modifiedTimes(root), times)

await writeFile(path.join(root, 'paraglide/runtime.js'), 'broken')
assert.deepEqual(await generateMessages(root), { changed: true })
await generateMessages(root)
assert.deepEqual(await output(root), reference)
})

Expand All @@ -110,7 +110,8 @@ describe('generated messages', () => {
assert.deepEqual(await output(root), reference)

await writeFile(catalog, JSON.stringify(MESSAGES))
assert.deepEqual(await generateMessages(root), { changed: false })
await generateMessages(root)
assert.deepEqual(await output(root), reference)
})

it('keeps complete outputs under concurrent generations', async context => {
Expand Down
187 changes: 6 additions & 181 deletions tooling/i18n/generateMessages/index.ts
Original file line number Diff line number Diff line change
@@ -1,204 +1,29 @@
import { Buffer } from 'node:buffer'
import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
mkdir,
readdir,
readFile,
rename,
rm,
writeFile
} from 'node:fs/promises'
import { createRequire } from 'node:module'
import path from 'node:path'
import { promisify } from 'node:util'

import * as v from 'valibot'
import { prepareMessages } from '../prepareMessages/index.ts'

import { readCatalogs } from '../readCatalogs/index.ts'

type GenerationResult = { changed: boolean }
type RootState = { fingerprint: string; outputs: Map<string, string> }
type GenerationState = {
queue: Promise<unknown>
roots: Map<string, RootState>
}
type WriteOptions = { file: string; content: string | Uint8Array }

const SETTINGS_FILE = 'project.inlang/settings.json'
const COMPILER_CONFIG = 'project.inlang/paraglide.config.ts'
const AGGREGATE_DIRECTORY = '.paraglide/messages'
const OUTPUT_DIRECTORY = 'paraglide'
const JSON_INDENT = 2
const GENERATED_SOURCE = /\.(?:js|d\.ts)$/u
const resolvePackage = createRequire(import.meta.url)
const COMPILER_CLI = path.resolve(
path.dirname(resolvePackage.resolve('@inlang/paraglide-js')),
'../bin/run.js'
)
const execute = promisify(execFile)
// Cargas repetidas da config compartilham a fila e o cache no processo.
const STATE_KEY = Symbol.for('boilerplate.i18n.generation')
const LocalesSchema = v.pipe(v.array(v.string()), v.minLength(1))
const SettingsSchema = v.object({
baseLocale: v.string(),
locales: LocalesSchema
})

let temporaryCount = 0

function isGenerationState(value: unknown): value is GenerationState {
return (
typeof value === 'object' &&
value !== null &&
'roots' in value &&
value.roots instanceof Map
)
}

function generationState(): GenerationState {
const current: unknown = Reflect.get(globalThis, STATE_KEY)
if (isGenerationState(current)) return current
const created: GenerationState = {
queue: Promise.resolve(),
roots: new Map()
}
Reflect.set(globalThis, STATE_KEY, created)
return created
}

async function serialized<T>(task: () => Promise<T>): Promise<T> {
const state = generationState()
const previous = state.queue
const { promise, resolve } = Promise.withResolvers<true>()
state.queue = promise
try {
await previous
return await task()
} finally {
resolve(true)
}
}

function digest(content: string | Uint8Array): string {
return createHash('sha256').update(content).digest('hex')
}

function isMissing(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
}

async function readExisting(file: string): Promise<Uint8Array | undefined> {
try {
return await readFile(file)
} catch (error) {
if (isMissing(error)) return undefined
throw error
}
}

// Os agregados entram por rename para o compilador não ler JSON pela metade.
async function writeIfChanged({
file,
content
}: WriteOptions): Promise<boolean> {
const next = typeof content === 'string' ? Buffer.from(content) : content
const current = await readExisting(file)
if (current && Buffer.compare(current, next) === 0) return false
temporaryCount += 1
const temporary = path.join(
path.dirname(file),
`.${path.basename(file)}.${process.pid}.${temporaryCount}.tmp`
)
await writeFile(temporary, next)
try {
await rename(temporary, file)
} catch (error) {
await rm(temporary, { force: true })
throw error
}
return true
}

async function isIntact(outputs: Map<string, string>): Promise<boolean> {
for (const [file, hash] of outputs) {
const content = await readExisting(file)
if (!content || digest(content) !== hash) return false
}
return true
}

async function readOutputs(outdir: string): Promise<Map<string, string>> {
const outputs = new Map<string, string>()
try {
for (const relative of await readdir(outdir, { recursive: true })) {
if (!GENERATED_SOURCE.test(relative)) continue
const file = path.join(outdir, relative)
outputs.set(file, digest(await readFile(file)))
}
} catch (error) {
if (!isMissing(error)) throw error
}
return outputs
}

async function generate(root: string): Promise<GenerationResult> {
const [settingsSource, compilerConfig] = await Promise.all([
readFile(path.join(root, SETTINGS_FILE), 'utf8'),
readFile(path.join(root, COMPILER_CONFIG), 'utf8')
])
const settings = v.parse(SettingsSchema, JSON.parse(settingsSource))
const catalogs = await readCatalogs({ root, ...settings })
const aggregates = settings.locales.map(locale => ({
file: path.join(root, AGGREGATE_DIRECTORY, `${locale}.json`),
content: `${JSON.stringify(catalogs[locale] ?? {}, null, JSON_INDENT)}\n`
}))
const fingerprint = digest(
JSON.stringify({ settingsSource, aggregates, compilerConfig })
)
const { roots } = generationState()
const previous = roots.get(root)
// Mesmos catálogos e a saída anterior intacta: nada a compilar.
if (
previous?.fingerprint === fingerprint &&
(await isIntact(previous.outputs))
) {
return { changed: false }
}
roots.delete(root)
await mkdir(path.join(root, AGGREGATE_DIRECTORY), { recursive: true })
for (const aggregate of aggregates) await writeIfChanged(aggregate)
const outdir = path.join(root, OUTPUT_DIRECTORY)
const before = await readOutputs(outdir)
// O CLI oficial reaproveita os hashes em disco, escreve apenas mudanças e
// remove módulos obsoletos.
export async function generateMessages(root = process.cwd()): Promise<void> {
const resolved = path.resolve(root)
await prepareMessages(resolved)
await execute(
'bun',
[
COMPILER_CLI,
'compile',
'--project',
path.join(root, 'project.inlang'),
'--outdir',
outdir,
path.join(resolved, 'project.inlang'),
'--silent'
],
{ cwd: root }
{ cwd: resolved }
)
const outputs = await readOutputs(outdir)
roots.set(root, { fingerprint, outputs })
return {
changed:
before.size !== outputs.size ||
[...outputs].some(([file, hash]) => before.get(file) !== hash)
}
}

// Valida os catálogos e atualiza paraglide. `changed` indica se algum
// arquivo da saída mudou. Arquivos gerados ficam sob a gestão do Paraglide.
export function generateMessages(
root = process.cwd()
): Promise<GenerationResult> {
const resolved = path.resolve(root)
return serialized(() => generate(resolved))
}
Loading
Loading