diff --git a/README.md b/README.md index 7e3612d..f032927 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,17 @@ published. The blog also feeds `/blog/rss.xml` and `/llms.txt`. Each post is translated into Spanish, Japanese and Brazilian Portuguese when it is published: `src/content/translations//.md` holds the title, the description and the body in that language, and takes the date, the kind and the draft flag from the English post, which is -the original. It is served at `//blog//`, with its own card, `hreflang` links to -every version and a note linking the original. The interface strings and the languages live in +the original. It is served at `//blog//`, with its own card and `hreflang` links to +every version, `x-default` being the original (set in `src/layouts/base.astro`). The interface strings and the languages live in `src/i18n.ts`. A translation whose original does not exist fails the build. +No page redirects by language: search engines pick the version through `hreflang`, and each link +that is shared points at its language. The original lists its translations in one line under the +post; a translation says where it comes from above it. Spanish is reviewed by the author and says +*translated*; Japanese and Portuguese are machine translations, say so, and link to a new issue to +report a mistake. [`GLOSSARY.md`](src/content/translations/GLOSSARY.md) fixes how each language +renders Bounda's vocabulary, and how a machine translation is checked before it is published. + ## Keeping the two sites coherent [`DESIGN.md`](DESIGN.md) is the design system both sites follow: palette, type, logo, the ledger diff --git a/src/components/blog/post-page.astro b/src/components/blog/post-page.astro index a0485d2..c175c6d 100644 --- a/src/components/blog/post-page.astro +++ b/src/components/blog/post-page.astro @@ -72,31 +72,20 @@ const structuredData = { {text.minutes(minutes)}

- { - locales.length > 1 && ( - - ) - } { locale !== "en" && (

{text.translated} {text.original} + { + text.report && ( + <> + {" · "} + + {text.report} + + + ) + }

) } @@ -105,6 +94,23 @@ const structuredData = {
+ { + locale === "en" && locales.length > 1 && ( +

+ {text.alsoIn}{" "} + {locales + .filter((other) => other !== "en") + .map((other, index) => ( + <> + {index > 0 && " · "} + + {LANGUAGES[other].name} + + + ))} +

+ ) + } @@ -215,40 +221,20 @@ const structuredData = { text-decoration: underline; } - .languages { - display: flex; - flex-wrap: wrap; - gap: 8px 18px; - align-items: baseline; - margin-top: 14px; - } - - .languages ul { - display: flex; - flex-wrap: wrap; - gap: 6px 16px; - margin: 0; - padding: 0; - list-style: none; + .also-in { + margin: 40px 0 0; font-size: 0.875rem; + color: var(--muted); } - .languages a { + .also-in a { color: var(--muted); - text-decoration: none; } - .languages a:hover, - .languages a[aria-current="page"] { + .also-in a:hover { color: var(--text); } - .languages a[aria-current="page"] { - text-decoration: underline; - text-decoration-color: var(--accent); - text-underline-offset: 4px; - } - .translated { margin: 14px 0 0; font-size: 0.875rem; diff --git a/src/content/translations/GLOSSARY.md b/src/content/translations/GLOSSARY.md new file mode 100644 index 0000000..3eb6ba4 --- /dev/null +++ b/src/content/translations/GLOSSARY.md @@ -0,0 +1,43 @@ +# Translation glossary + +How the blog's translations render Bounda's vocabulary, so every post says the same thing the same +way. Taken from the posts already published; a new term is added here the first time a post uses +it. Code, identifiers, file names and error codes are never translated. + +Spanish is reviewed by the author. Japanese and Brazilian Portuguese are machine translations, +checked before publishing by translating them back to English and comparing them with the +original, and labelled as such on the page, with a link to report a mistake. + +| English | es | ja | pt-BR | +|---|---|---|---| +| event sourcing | event sourcing | イベントソーシング | event sourcing | +| event store | event store | イベントストア | event store | +| event | evento | イベント | evento | +| command | comando | コマンド | comando | +| query | query | クエリ | query | +| aggregate | agregado | 集約 | agregado | +| state | estado | 状態 | estado | +| stream | stream | ストリーム | stream | +| fold (noun / verb) | fold / plegar | 畳み込み / 畳み込む | fold / fazer o fold | +| read model | read model | リードモデル | read model | +| projection | proyección | プロジェクション | projeção | +| policy | policy | ポリシー | policy | +| process | process | プロセス | process | +| handler | handler | ハンドラー | handler | +| payload | payload | ペイロード | payload | +| rejection | rechazo | 拒否 | rejeição | +| snapshot | snapshot | スナップショット | snapshot | +| idempotency key | clave de idempotencia | 冪等性キー | chave de idempotência | +| dead letter | dead letter | デッドレター | dead letter | +| port | puerto | ポート | porta | +| crypto-shredding | crypto-shredding | クリプトシュレッディング | crypto-shredding | +| in the clear | en claro | 平文で | sem criptografia | + +Notes: + +- **Do not add** what the original does not say: no local laws, examples or asides. A translation + that needs context for its readers gets it from the author, in the original. +- Link texts that point to English pages stay in English, followed by "(in English)" in the + language of the page when that helps: (en inglés), (英語), (em inglês). +- pt-BR uses Brazilian conventions: “ ” quotes, "você", the subjunctive after *sempre que*. +- A scene of the demo keeps its English name, with a short gloss in parentheses. diff --git a/src/content/translations/ja/right-to-be-forgotten-event-sourcing.md b/src/content/translations/ja/right-to-be-forgotten-event-sourcing.md index 5dac503..df467cb 100644 --- a/src/content/translations/ja/right-to-be-forgotten-event-sourcing.md +++ b/src/content/translations/ja/right-to-be-forgotten-event-sourcing.md @@ -1,6 +1,6 @@ --- title: 決して削除しないログで「忘れられる権利」を実現する -description: イベントソーシングはすべての事実を永遠に残し、GDPR は本人に消去を求める権利を与えます。クリプトシュレッディングがその両方をどう両立させるか、何が漏れるか、そしてイベントストアが取る三つのアプローチ。 +description: イベントソーシングはすべての事実を永遠に残し、GDPR は本人に消去を求める権利を与えます。クリプトシュレッディングがその両方をどう両立させるか、何を取りこぼすか、そしてイベントストアが取る三つのアプローチ。 tryIt: label: デモで購入者を忘れる --- diff --git a/src/content/translations/pt-br/a-command-is-a-file.md b/src/content/translations/pt-br/a-command-is-a-file.md index 7f2a017..de975a3 100644 --- a/src/content/translations/pt-br/a-command-is-a-file.md +++ b/src/content/translations/pt-br/a-command-is-a-file.md @@ -1,8 +1,8 @@ --- title: Um comando é um arquivo -description: Uma feature inteira com event sourcing em dois arquivos pequenos, e as cinco coisas que você deixa de escrever para chegar lá. De onde vem esse formato, e quanto ele custa. +description: Uma feature inteira com event sourcing em dois arquivos pequenos, e as cinco coisas que você deixa de escrever para chegar lá. De onde vem esse formato, e o que ele custa. tryIt: - label: Bata no limite de lugares na demo ao vivo + label: Chegue ao limite de lugares na demo ao vivo --- Event sourcing tem fama de cerimônia. Antes de guardar o primeiro fato, você já escreveu uma classe @@ -168,10 +168,10 @@ projeção trata. Os frameworks de event sourcing já consolidados modelam o agregado como uma classe: uma classe anotada no [Axon](https://docs.axoniq.io/axon-framework-reference/5.4/commands/entities/event-sourced-entity/), -uma classe simples dobrada por métodos `Apply` no +uma classe simples que faz o fold com métodos `Apply` no [Marten](https://martendb.io/events/projections/aggregate-projections.html). É um design sólido, comprovado ao longo de anos, e é o que permite ao Axon rodar o mesmo handler numa frota de -serviços. Para um app TypeScript, porém, funções em arquivos dão três coisas que uma classe não dá: +serviços. Para um único app TypeScript, porém, funções em arquivos dão três coisas que uma classe não dá: - **Um estado que não pode se desviar dos eventos.** Ele é o que os eventos devolvem, então não há uma segunda declaração para manter em sincronia à mão. diff --git a/src/content/translations/pt-br/right-to-be-forgotten-event-sourcing.md b/src/content/translations/pt-br/right-to-be-forgotten-event-sourcing.md index a3bd863..9b0d12e 100644 --- a/src/content/translations/pt-br/right-to-be-forgotten-event-sourcing.md +++ b/src/content/translations/pt-br/right-to-be-forgotten-event-sourcing.md @@ -1,6 +1,6 @@ --- title: O direito ao esquecimento, num log que nunca apaga -description: Event sourcing guarda cada fato para sempre; a GDPR (e a LGPD) deixam uma pessoa pedir que seus dados sejam apagados. Como o crypto-shredding torna as duas coisas verdade, o que escapa e os três jeitos como os event stores o abordam. +description: Event sourcing guarda cada fato para sempre; a GDPR deixa uma pessoa pedir que seus dados sejam apagados. Como o crypto-shredding torna as duas coisas verdade, o que escapa e os três jeitos como os event stores o abordam. tryIt: label: Esqueça um comprador na demo --- @@ -9,8 +9,8 @@ Cedo ou tarde chega um e-mail: *por favor, apaguem todos os dados que vocês tê seu sistema guarda o estado em tabelas, é uma tarde de comandos `DELETE` e uma conferida nos backups. Se ele usa event sourcing, é uma crise de identidade. A graça do event store é que nada do que entra nele é alterado ou removido. É isso que torna uma reconstrução fiel, uma trilha de -auditoria completa e um bug reproduzível. E agora a lei, o artigo 17 da GDPR (no Brasil, o artigo 18 da -LGPD prevê o mesmo direito), afirma que a parte de uma pessoa precisa sumir. +auditoria completa e um bug reproduzível. E agora a lei, o artigo 17 da GDPR, afirma que a parte +de uma pessoa precisa sumir. As equipes costumam recorrer a uma de duas respostas, e as duas estão erradas. A primeira é reescrever a história: encontrar os eventos da pessoa e editá-los no lugar. Isso quebra todas as @@ -25,13 +25,13 @@ Existe uma terceira resposta, e ela começa por uma pergunta que a maioria pula. Um evento registra uma decisão. `OrderPlaced` é o fato de que o cliente `c-42` pediu dois itens. O nome, o endereço e o telefone dele não fazem parte desse fato: são detalhes de que a transportadora precisa e que a nota fiscal imprime. Um command handler que os copia para o evento -porque «estavam no comando» fez do evento o dono de dados que pertencem a outra parte do sistema. +porque “estavam no comando” fez do evento o dono de dados que pertencem a outra parte do sistema. Então a primeira ferramenta não é criptografia, é uma fronteira. O evento leva o id, os detalhes ficam atrás de uma porta cuja implementação sabe apagá-los, e apagar a pessoa é um `DELETE` nesse armazenamento. Mathias Verraes chama isso de [forgettable payloads](https://verraes.net/2019/05/eventsourcing-patterns-forgettable-payloads). -Basta sempre que o dado não é necessário para explicar o passado, e ainda o mantém fora de todas +Basta sempre que o dado não for necessário para explicar o passado, e ainda o mantém fora de todas as cópias do log. Mas às vezes o dado *é* a decisão. `AddressChanged` registra o endereço para onde alguém se mudou. @@ -55,8 +55,8 @@ Este é o stream de um cliente, antes e depois: | `001 204` | `PersonalDataForgotten` | | | Nada acima da última linha foi editado. As posições são as mesmas, os tipos de evento são os -mesmos, o pedido continua sendo dobrado e os read models continuam sendo reconstruídos. Só a chave -sumiu. +mesmos, o fold do pedido continua funcionando e os read models continuam sendo reconstruídos. Só +a chave sumiu. No [Bounda](https://docs.bounda.dev/getting-started/), marcar um campo é uma palavra no schema do evento: @@ -81,7 +81,7 @@ seja um fold, uma projeção ou uma policy, recebe `string | null`. Esse `null` não é questão de estilo. É o que faz o compilador levar você a cada lugar que supunha que o e-mail sempre estaria lá: a projeção que o copiava para uma coluna `NOT NULL`, o fold que o passava para minúsculas, a policy que envia o e-mail de boas-vindas. Os eventos de uma pessoa -esquecida continuam sendo dobrados, projetados e reproduzidos. Eles carregam um buraco onde o dado +esquecida continuam passando pelo fold, pela projeção e pelo replay. Eles carregam um buraco onde o dado estava, e o seu código é obrigado a ter decidido o que esse buraco significa antes de chegar o primeiro pedido de exclusão, não depois. @@ -95,7 +95,7 @@ Apagar a chave torna os eventos ilegíveis. Não faz nada com tudo o que foi con deles, e num sistema com event sourcing muita coisa é construída a partir deles: - **Read models.** O diretório de clientes projetou o e-mail numa linha. -- **Snapshots.** O estado dobrado do agregado, guardado em claro para que um stream longo carregue +- **Snapshots.** O estado do agregado após o fold, guardado sem criptografia para que um stream longo carregue rápido. - **Respostas lembradas.** Um comando enviado com uma chave de idempotência guarda o que respondeu, e a mensagem de uma rejeição pode citar o estado em que se baseou. @@ -157,8 +157,7 @@ dados. - **Os backups guardam as chaves.** As chaves ficam ao lado dos eventos, então um backup do banco é uma cópia dos dados enquanto for mantido. -- **Os logs guardam o que você imprimiu.** Se um handler registrou um payload, o log o tem em - claro. +- **Os logs guardam o que você imprimiu.** Se um handler registrou um payload, o log o tem em texto puro. - **O que saiu do sistema continua lá fora,** a menos que uma policy vá apagá-lo. - **Campos não marcados são texto puro.** A marca é por campo e por evento: esqueça de marcar um e ele fica legível para sempre. diff --git a/src/i18n.ts b/src/i18n.ts index 3b24141..2ea934d 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -26,6 +26,10 @@ export interface Language { readonly languages: string; readonly translated: string; readonly original: string; + /** Only for machine translations, which a native reader is asked to correct. */ + readonly report: string; + /** Under the original, before the translations it has. Only English uses it. */ + readonly alsoIn: string; readonly newsletter: Newsletter; } @@ -55,6 +59,8 @@ export const LANGUAGES: Record = { languages: "Read in", translated: "", original: "", + report: "", + alsoIn: "Also in", newsletter: { heading: "Newsletter", lead: "Each new post by email. Nothing else.", @@ -79,6 +85,8 @@ export const LANGUAGES: Record = { languages: "Leer en", translated: "Traducido del original en inglés.", original: "Leer el original", + report: "", + alsoIn: "", newsletter: { heading: "Newsletter", lead: "Cada post nuevo por email. Nada más.", @@ -101,8 +109,10 @@ export const LANGUAGES: Record = { more: "ほかの記事", seeItRun: "ライブデモで動きを見る", languages: "言語", - translated: "英語の原文からの翻訳です。", + translated: "英語の原文からの機械翻訳です。", original: "原文を読む", + report: "誤りを報告する", + alsoIn: "", newsletter: { heading: "ニュースレター", lead: "新しい記事をメールで。それ以外は送りません。", @@ -125,8 +135,10 @@ export const LANGUAGES: Record = { more: "Mais posts", seeItRun: "Veja funcionando na demo", languages: "Ler em", - translated: "Traduzido do original em inglês.", + translated: "Tradução automática do original em inglês.", original: "Ler o original", + report: "Avisar de um erro", + alsoIn: "", newsletter: { heading: "Newsletter", lead: "Cada post novo por email. Nada mais.",