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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,9 @@ jobs:

- name: go test
run: go test ./...

# Informational, never fails: the tests in internal/i18n are the gate. This prints
# per-language coverage so a translation PR shows up in the log with its progress,
# and so a language falling under the floor is visible before a user meets it.
- name: Translation coverage
run: go run ./cmd/i18n-coverage
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad

## [Unreleased]

### Changed

- **Partial locale catalogues are allowed, so a language can be translated in stages.** A
locale file no longer has to contain every English key before it can merge: a missing key
falls back to English at lookup time (`Locale.T`). This was previously blocked by
`TestAllLocalesHaveTheSameKeys`, which asserted exact set equality in both directions, so a
half-finished locale was unmergeable and a volunteer had to produce all 169 strings before
contributing anything. That is the practical reason community translations stall.
A locale is now offered to visitors - language switcher, `Accept-Language` matching and the
public API payload - only at or above `MinCoverage` (80% of the English keys), so nobody is
served a mostly-English booking page by accident. Below the floor `?lang=<code>` still
resolves, so whoever is translating it can check their work, and startup logs which locales
are withheld and why. The unknown-key check survives as `TestLocalesHaveNoUnknownKeys`,
since a key English does not have is always a typo, and the floor is pinned by
`TestOfferedLocalesMeetCoverageFloor`. No shipped locale changes: all 11 are complete and
remain offered.

## [0.13.0] - 2026-11-03

### Added
Expand Down
47 changes: 31 additions & 16 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,34 +74,49 @@ prefer additive, nullable/defaulted columns.

## Translations

Calnode ships 11 locales (`en es fr fr-CA de el it nl pl pt sv`) across the booker-facing surfaces:
Calnode ships 11 locales (`en es fr fr-CA de el it nl pl pt sv`) on the booker-facing surfaces:
booking page, manage/reschedule page, embed widget, the four emails, and the calendar
invite. The admin UI and the built-in video room are English-only.
invite. The admin SPA has its own catalogue, in progress.

**Translation PRs are very welcome** - both new languages and corrections to existing
ones. **Most non-English locales are machine drafts with no native review, and none has
had a full native read** (Polish was contributed and grammar-checked by a native
speaker), so if you speak one of them, fixes are genuinely valuable and will be merged
readily. Small, single-language PRs are easier to review than sweeping ones.

**Adding a language is adding one file:** `internal/i18n/locales/<code>.json`. No Go,
template, or frontend change is needed - `init()` globs the directory, and the language
switcher, the fallback-language setting and the public API payload all read
`SupportedLocales()`.

1. Copy `internal/i18n/locales/en.json` and translate the values. Keep every key, keep
the key order (it keeps diffs readable), and keep printf verbs (`%s`, `%d`, `%q`)
intact - reordering them is fine with indexed verbs (`%[2]s`), dropping them is not.
2. Name the file **BCP-47 canonical**: `pt-BR.json`, never `pt-br.json`.
3. `dow_short_*`, `month_short_*`, `date_format` and `clock_format` are data, not code -
A language is **one file, and it does not have to be complete.**

1. Copy `internal/i18n/locales/en.json` to `internal/i18n/locales/<code>.json` and translate
the values. Delete the keys you have not got to yet if that helps; a missing key falls
back to English at lookup time, so a partial file is mergeable and useful.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tells translators a missing key falls back to English and that ?lang= is a safe way to preview a partial file, but that only holds for server-rendered copy. The JS-driven tables on the booking page, manage page and embed widget are built from Locale.JSON() and return the raw key on a miss, so a translator previewing a partial locale will see confirm_booking rather than English. Either qualify this section (fallback applies to server-rendered strings only) or land the Locale.JSON() English merge before advertising the workflow.

Technical details
# Partial-locale preview is misleading on JS surfaces

## Affected sites
- `CONTRIBUTING.md:91` — promises English fallback for a missing key.
- `CONTRIBUTING.md:107` — says `?lang=` is how to check work in place.
- `internal/i18n/i18n.go:233` — `Locale.JSON()` marshals `l.strings` only, no English merge.
- `internal/handler/templates/book.html:281`, `book.html:856`, `manage.html:271` — `t(key)` returns `key` on a miss.
- `internal/handler/embed.js:42` — same lookup.

## Required outcome
The contributor guide must not promise behaviour the client surfaces do not implement. Either qualify the fallback claim to server-rendered copy, or merge English into `Locale.JSON()` (tracked from the prior review) so the claim becomes true everywhere.

2. Keep the keys spelled exactly as English has them. A key that is not in `en.json` fails
the build (`TestLocalesHaveNoUnknownKeys`), because it is always a typo and renders for
nobody.
3. Keep printf verbs intact: `%s`, `%d`, `%q`. Reordering is fine with indexed verbs
(`%[2]s`); dropping one is not. In the admin catalogue the placeholders are `{name}`.
4. Name the file **BCP-47 canonical**: `pt-BR.json`, never `pt-br.json`.
5. `dow_short_*`, `month_short_*`, `date_format` and `clock_format` are data, not code -
Go has no locale date tables. Match CLDR; the test below checks you against `Intl`.
4. Run `go test ./internal/i18n/`. Three guards will tell you exactly what is wrong:
key parity, printf-verb parity (with `fmt` as the oracle), and a CLDR cross-check of
the date tables (needs `node` on PATH; skipped if absent).
5. Eyeball the result: `go run ./cmd/calnode`, then
6. Run `go test ./internal/i18n/` and `make i18n-coverage` to see where you stand.
7. Eyeball the result: `go run ./cmd/calnode`, then
`http://localhost:3000/book/<slug>?lang=<code>` and the manage page. Long words
overflowing buttons is the usual surprise; check mobile width too.

**When a language is offered.** A language appears in the switcher, and is matched from
`Accept-Language`, only at or above 80% of the English keys (`i18n.MinCoverage`). Below the
floor it still resolves with `?lang=<code>`, so you can check your work in place, and
startup logs which languages are withheld. The floor exists because a mostly-English
booking page is worse for a booker than English is.

**What passing tests does not prove.** They check structure, not wording. Before a language
is advertised, a speaker should read the booking page, the manage page and the four emails
end to end. The usual misses are literal calques, agreement when two translated sentences
are spliced together, and plurals written as flat strings.

Terminology is agreed in [`docs/i18n-glossary.md`](docs/i18n-glossary.md). Add your
language's column there rather than inventing a second word for something that already has
one.

Background and the full list of limitations (2-form plurals, no RTL) are in
[ARCHITECTURE.md §23](docs/ARCHITECTURE.md).

Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: frontend backend build
.PHONY: frontend backend build i18n-coverage

frontend:
cd frontend && pnpm install && pnpm build
Expand All @@ -7,3 +7,8 @@ backend: frontend
go build -o calnode ./cmd/calnode

build: backend

# Per-language translation coverage, for both catalogues. Informational; the tests in
# internal/i18n are the gate.
i18n-coverage:
go run ./cmd/i18n-coverage
157 changes: 157 additions & 0 deletions cmd/i18n-coverage/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Command i18n-coverage prints how much of each catalogue every shipped language
// carries.
//
// It is a progress report, not a gate: the tests in internal/i18n are what fail a
// build. This exists because a translator needs to see 512/1324 rather than guess, and
// a reviewer needs to see whether a language is close enough to offer before reading it.
//
// Run it from the repository root:
//
// go run ./cmd/i18n-coverage
// make i18n-coverage
package main

import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"github.com/calnode/calnode/internal/i18n"
)

type catalogue struct {
name string
dir string
// floor is the coverage below which the language is not offered to visitors.
// Zero means "no floor for this catalogue yet".
floor float64
}

const sourceCode = "en"

func main() {
root := flag.String("root", ".", "repository root")
flag.Parse()

catalogues := []catalogue{
{name: "booker (public surfaces)", dir: "internal/i18n/locales", floor: i18n.MinCoverage},
{name: "admin (SPA)", dir: "frontend/src/lib/i18n", floor: 0},
}

for _, c := range catalogues {
if err := report(*root, c); err != nil {
fmt.Fprintf(os.Stderr, "i18n-coverage: %s: %v\n", c.dir, err)
os.Exit(1)
}
}
}

func report(root string, c catalogue) error {
dir := filepath.Join(root, c.dir)
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
fmt.Printf("%s\n %s does not exist yet, skipped\n\n", c.name, c.dir)
return nil
}
if err != nil {
return err
}

source, err := load(filepath.Join(dir, sourceCode+".json"))
if err != nil {
return err
}
if len(source) == 0 {
return fmt.Errorf("%s has no strings", filepath.Join(dir, sourceCode+".json"))
}

type row struct {
code string
keys int
cover float64
unknown int
}
var rows []row
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
code := strings.TrimSuffix(e.Name(), ".json")
strings_, err := load(filepath.Join(dir, e.Name()))
if err != nil {
return err
}
r := row{code: code, keys: len(strings_)}
for k := range strings_ {
if _, ok := source[k]; ok {
r.cover++
} else {
r.unknown++
}
}
r.cover = float64(r.cover) / float64(len(source))
rows = append(rows, r)
}
sort.Slice(rows, func(i, j int) bool { return rows[i].code < rows[j].code })

fmt.Printf("%s\n %s (source %s.json, %d keys)\n\n", c.name, c.dir, sourceCode, len(source))
fmt.Printf(" %-7s %6s %8s %10s %8s\n", "code", "keys", "coverage", "status", "unknown")
for _, r := range rows {
status := "source"
switch {
case r.code == sourceCode:
case r.unknown > 0:
status = "has unknown keys"
case c.floor > 0 && r.cover < c.floor:
status = "withheld"
default:
status = "offered"
}
fmt.Printf(" %-7s %6d %7.0f%% %10s %8d\n", r.code, r.keys, r.cover*100, status, r.unknown)
}
if c.floor > 0 {
fmt.Printf("\n floor: %.0f%% (below it a language is not offered; ?lang=<code> still works)\n", c.floor*100)
}
fmt.Println()
return nil
}

// load reads one catalogue file and returns its flattened key set. Nested objects are
// joined with dots, so the admin catalogue's plural members count individually
// ("common.save", "teams.memberCount.one", ...). Arrays are not used in either
// catalogue and are skipped rather than guessed at.
func load(path string) (map[string]struct{}, error) {
// #nosec G304 -- path is the repository root this developer tool was invoked from
// plus a fixed catalogue name; there is no untrusted input, and this binary is never
// served. It reports on files a contributor is already editing.
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var raw map[string]any
if err := json.Unmarshal(b, &raw); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
out := map[string]struct{}{}
flatten("", raw, out)
return out, nil
}

func flatten(prefix string, v any, out map[string]struct{}) {
switch t := v.(type) {
case map[string]any:
for k, child := range t {
key := k
if prefix != "" {
key = prefix + "." + k
}
flatten(key, child, out)
}
case string:
out[prefix] = struct{}{}
}
}
24 changes: 23 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1308,9 +1308,28 @@ you is a key in the JSON instead (`internal/i18n/datetime.go`): `date_format`, `
`dow_short_*`, `month_short_*`. That also makes per-language date shape expressible - German
carries its own `date_format` for the ordinal period ("Mo, 22. Jun 2026").

A locale does **not** have to be complete to merge. Partial catalogues are supported: any
key a locale lacks falls back to English at lookup time (`Locale.T`), so a translation can
land in stages instead of in one block. The practical reason is that nobody translates 169
strings in one sitting, and requiring completeness made an in-progress language
unmergeable - which is how community translations quietly die.

The other half of that bargain is `MinCoverage` (80% of the English keys). A locale at or
above it is offered to visitors: the language switcher, `Accept-Language` matching and the
public API payload all read `SupportedLocales()`, which filters on it. A locale below the
floor still exists and `?lang=<code>` still resolves, so whoever is translating it can
check their work in situ, but no visitor is served a mostly-English booking page by
accident. The floor is set with headroom because the English catalogue grows: adding
strings can push a locale under it, and that is the signal to chase the translators.
Startup logs a line for every locale it withholds and why.

Three guards police a new file (`go test ./internal/i18n/`):

- `TestAllLocalesHaveTheSameKeys` - no missing or unknown keys.
- `TestLocalesHaveNoUnknownKeys` - no key that English does not have. A missing key is
fine now (that is the fallback), but an unknown one is always a typo or a renamed key
and would render for nobody. This replaced `TestAllLocalesHaveTheSameKeys`, which
asserted set equality in both directions and therefore rejected the in-progress locales
the floor exists to support.
- `TestAllLocalesHaveMatchingFormatVerbs` - uses `fmt` **itself** as the oracle, so a locale
that drops or reorders a `%s`/`%d` fails the test instead of emitting `%!(EXTRA…)` to a
booker. An earlier hand-rolled parser for this was unsound (it missed indexed verbs like
Expand All @@ -1320,6 +1339,9 @@ Three guards police a new file (`go test ./internal/i18n/`):
only check that can tell `dim.` from `dim`, or catch a 12h/24h call that is wrong for the
language. It caught a real drift on first run (`es` had `sep` where CLDR says `sept`).

`TestOfferedLocalesMeetCoverageFloor` pins the invariant the three guards do not: nothing
below `MinCoverage` is ever in `supported`.

Two traps:

- **Filenames must be BCP-47 canonical.** `pt-br.json` is wrong - `language.Make("pt-br")`
Expand Down
64 changes: 64 additions & 0 deletions docs/i18n-glossary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Translation glossary

One concept, one word, per language. The point is that a booking does not become a
"rendez-vous" on one screen and a "réservation" on the next, and that a new translator does
not have to invent terminology that is already decided.

**How to use it**

- Before translating a term, look it up here. If it is missing, add it with its key.
- The values below were read from the shipped catalogues, not invented. If a catalogue
disagrees with this table, the table is wrong and should be fixed in the same PR.
- **Adding a language means adding a column.** Do not introduce a second word for a concept
that already has one.
- Two catalogues exist: the **booker** surfaces (`internal/i18n/locales`) and the **admin**
SPA (`frontend/src/lib/i18n`). They are separate systems with separate registers, and they
currently disagree in places. Those rows are marked **divergent** and are decisions, not
accidents.

## Core terms

| Concept | English | French (booker) | French (admin) | Notes |
|---|---|---|---|---|
| booking (the object) | booking | réservation | réservation | Never "rendez-vous" for the object. |
| to book (verb) | book | réserver | réserver | |
| event type | event type | - | type de rendez-vous | **divergent**: the booker has no public name for it. |
| host | host | - | hôte | The booker addresses the booker, not the host. |
| attendee / booker | attendee | - | participant | Use "participant" in admin; the booker is "vous". |
| team | team | équipe | équipe | |
| team member | team member | membre de l'équipe | membre | |
| availability | availability | disponibilités | disponibilités | Plural in French. |
| slot / time slot | slot | créneau | créneau | |
| timezone | timezone | fuseau horaire | fuseau horaire | |
| reschedule | reschedule | reprogrammer | reprogrammer | Noun: "reprogrammation". |
| cancel | cancel | annuler | annuler | Noun: "annulation". |
| round robin | round robin | - | tourniquet | French admin uses "tourniquet"; do not calque "tourner entre eux". |
| reminder | reminder | rappel | rappel | |
| directory | directory | - | annuaire | Only the person and team pages. |

## Register

| Surface | Register | Reason |
|---|---|---|
| Booker | Formal, second person plural (`vous`) | A customer booking a call from a business page. |
| Admin | Formal (`vous`), terse | An operator working in the tool all day. |

The existing booker locales are deliberately split by market: `fr`/`de`/`it`/`pt` formal,
`es`/`nl`/`sv` informal. Match the language, not the English.

## Things that are not translated

Brand names, URLs, protocol tokens (`STARTTLS`, `RSVP`, `PII`), third-party console labels
("APIs & Services", "Authorised redirect URIs"), and code identifiers. Translating those is
almost always a mistake; leave them byte-identical.

## Placeholders

Never translate a placeholder. `%s`, `%d`, `%q` (booker) and `{name}` (admin) must survive
verbatim, and a missing one is a runtime bug rather than a stylistic choice. The tests check
this, but a test cannot see that you moved a word into the wrong place.

## Cross-references

- `CONTRIBUTING.md` - how to add or extend a language
- `docs/ARCHITECTURE.md` §23 - how the catalogue system works and its limitations
Loading
Loading