From 82df0bd6a0a3a1cb102eb2941b50a883364ffc0a Mon Sep 17 00:00:00 2001 From: Sean Dean <254259913+distronode-com@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:09:22 -0400 Subject: [PATCH] i18n: ship Canadian French as its own locale, not as a fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fr-CA` currently resolves to the France copy, and the differences are not stylistic. `courriel` rather than `e-mail`, `reporter`/`report` rather than `reprogrammer`/`reprogrammation`, `renseignements personnels` rather than `données personnelles` (the Quebec statutory term, so the wrong one is wrong in a legal sense rather than a register one), and no space before `!` `?` `;` where France puts one. CLDR itself disagrees on an abbreviation: July is `juill.` in fr-CA against `juil.` in fr. It is the first REGIONAL locale, which is the only structural thing here: the matcher already prefers an exact tag, so `fr-FR` and plain `fr` are untouched by this file existing. TestResolve now pins that in both directions rather than only the new one, since the half that would break silently is the French visitor who never asked for any of this. The three guards the other eight locales already pass apply unchanged — same key set as English, matching printf verbs, and date tables cross-checked against CLDR — so the structure is verified even though the wording is not. ⚠️ The copy is an unreviewed draft, like every non-English locale here. Nobody who speaks Canadian French has read it. That is said plainly in the CHANGELOG so a correction arrives as an ordinary pull request rather than as a bug report. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 +++ CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- docs/ARCHITECTURE.md | 13 ++- internal/i18n/i18n_test.go | 7 ++ internal/i18n/locales/fr-CA.json | 160 +++++++++++++++++++++++++++++++ 6 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 internal/i18n/locales/fr-CA.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b496eae..bbf1534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,19 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad `-copy-2`, `-copy-3`, ...) slug, and keeps `price_cents`/`currency` verbatim: zeroing a copied price is how a paid meeting quietly starts selling for nothing. Bookings are not copied. +- **Canadian French (`fr-CA`) on the booker-facing surfaces.** A visitor whose browser asks + for `fr-CA` now gets Canadian French rather than the France copy; `fr` and `fr-FR` are + unaffected. It is the first regional locale, and a separate file rather than a fallback + because the differences are real: `courriel` rather than `e-mail`, `reporter`/`report` + rather than `reprogrammer`, `renseignements personnels` (the Quebec statutory term) rather + than `données personnelles`, no space before `!` `?` `;` where France puts one, and CLDR + itself spells July `juill.` here against `juil.` in France. + + ⚠️ **The wording is an unreviewed draft**, like every non-English locale in this + repository: the structure is verified by the same three guards (same keys, printf-verb + parity, date tables cross-checked against CLDR), but no native Canadian French speaker has + read the copy. Corrections are welcome and easy to merge — see CONTRIBUTING. + - **Empty days and minimum-notice gaps now explain themselves** on all three booking surfaces (booking page, manage/reschedule page, embed widget). Closes [#20](https://github.com/Calnode/calnode/issues/20). diff --git a/CLAUDE.md b/CLAUDE.md index 91ab512..c788374 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,7 +140,7 @@ no string plumbing with the Go templates), and admin-authored content (event nam descriptions, questions, custom email copy). Locale is resolved per request from `Accept-Language` + a `?lang=` override + the operator's fallback setting (`internal/handler/i18n.go`), and the booker's locale is stored on the booking so later -reminders match. Ships `en es fr de it pt nl sv`. Full detail: ARCHITECTURE §23. +reminders match. Ships `en es fr fr-CA de it pt nl sv`. Full detail: ARCHITECTURE §23. **Adding a locale = adding `internal/i18n/locales/.json`.** Nothing else. `init()` globs the directory; the switcher, the fallback dropdown and the public API payload all read diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 148c9d2..76870aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,7 +74,7 @@ prefer additive, nullable/defaulted columns. ## Translations -Calnode ships 8 locales (`en es fr de it pt nl sv`) across the booker-facing surfaces: +Calnode ships 9 locales (`en es fr fr-CA de it pt nl sv`) across 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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e91d5db..d401a8e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1048,7 +1048,18 @@ LLM summary) — the next build; consent-gated (§8.11/§15 of the PRD). ## 23. Languages (i18n) -Calnode ships **8 locales**: `en` (source) · `es` · `fr` · `de` · `it` · `pt` · `nl` · `sv`. +Calnode ships **9 locales**: `en` (source) · `es` · `fr` · `fr-CA` · `de` · `it` · `pt` · `nl` · `sv`. + +`fr-CA` is the first **regional** locale, and it is a separate file rather than a fallback +because the differences are real: `courriel` not `e-mail`, `reporter`/`report` not +`reprogrammer`/`reprogrammation`, `renseignements personnels` never `données personnelles` +(the Quebec statutory term), no space before `!` `?` `;` where France puts one, and CLDR +itself disagrees on one abbreviation — `month_short_jul` is `juill.` in fr-CA and `juil.` in +fr, which is exactly what `TestDateTablesMatchCLDR` exists to catch. Both keep the 24-hour +clock and the day-month `date_format`. Currency and percent take a non-breaking space before +`$` and `%` in Canadian French; no key carries either today, so the rule is recorded here +rather than applied. A visitor sending `fr-FR` or plain `fr` is unaffected — the matcher +picks the exact tag first (pinned in `TestResolve`). ### What is translated, and what is not diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go index 0d0d9ca..e3afc58 100644 --- a/internal/i18n/i18n_test.go +++ b/internal/i18n/i18n_test.go @@ -37,6 +37,13 @@ func TestResolve(t *testing.T) { {"exact es", "es", "", "es"}, {"exact en", "en-US,en;q=0.9", "", "en"}, {"regional subtag falls back to primary", "es-MX,es;q=0.9", "", "es"}, + // fr-CA is shipped as its own file, so a Canadian browser gets Canadian French + // rather than the France copy — and a French browser is unaffected by its + // existence, which is the half that would break silently. + {"Canadian French resolves to its own locale", "fr-CA,fr;q=0.9", "", "fr-CA"}, + {"European French is unaffected by fr-CA", "fr-FR,fr;q=0.9", "", "fr"}, + {"plain fr is unaffected by fr-CA", "fr", "", "fr"}, + {"fr-CA can be selected by the ?lang override", "en", "fr-CA", "fr-CA"}, {"unsupported language falls back to English", "ja-JP,ja;q=0.9", "", "en"}, {"empty header falls back to English", "", "", "en"}, {"garbage header falls back to English", "not a real header ;;;", "", "en"}, diff --git a/internal/i18n/locales/fr-CA.json b/internal/i18n/locales/fr-CA.json new file mode 100644 index 0000000..d772508 --- /dev/null +++ b/internal/i18n/locales/fr-CA.json @@ -0,0 +1,160 @@ +{ + "book_by_chat": "Réserver par chat", + "close": "Fermer", + "assistant_greeting": "Bonjour! Dites-moi approximativement quand vous souhaitez nous rencontrer, par exemple « mardi après-midi » ou « la semaine prochaine », et je trouverai un créneau. Vous pouvez aussi utiliser le calendrier.", + "assistant_input_placeholder": "Indiquez vos disponibilités...", + "assistant_input_aria": "Envoyer un message à l'assistant de réservation", + "send": "Envoyer", + "show_more": "Afficher plus", + "show_less": "Afficher moins", + "date_picker_aria": "Sélecteur de date", + "timezone_aria": "Fuseau horaire", + "select_day_hint": "Sélectionnez un jour pour voir les créneaux disponibles.", + "back": "Retour", + "name_label": "Nom", + "name_placeholder": "Votre nom complet", + "email_label": "Courriel", + "email_placeholder": "vous@exemple.com", + "choose_option": "Choisissez une option", + "confirm_booking": "Confirmer la réservation", + "confirming": "Confirmation...", + "booking_confirmed": "Réservation confirmée", + "confirmation_email_sent": "Un courriel de confirmation vous a été envoyé.", + "required_fields_error": "Veuillez remplir tous les champs obligatoires.", + "slot_taken_error": "Ce créneau vient d'être réservé. Veuillez en choisir un autre.", + "booking_failed_error": "La réservation a échoué. Veuillez réessayer.", + "network_error": "Erreur de connexion. Vérifiez votre connexion internet.", + "loading": "Chargement...", + "no_available_times": "Aucun créneau disponible le %s.", + "no_available_times_host": "%[1]s n'a aucun créneau disponible le %[2]s.", + "slot_taken": "déjà réservé", + "all_times_taken": "Tous les horaires de cette journée sont réservés.", + "min_notice_hint": "Les réservations doivent être effectuées au moins %s à l'avance.", + "could_not_load_times": "Impossible de charger les créneaux. Veuillez réessayer.", + "times_shown_in": "Horaires affichés en ", + "payment_received": "Paiement reçu", + "payment_confirmed_sub": "Votre réservation est confirmée; un courriel de confirmation va vous parvenir.", + "assistant_error": "Désolé, une erreur est survenue. Veuillez utiliser le calendrier ci-dessous.", + "assistant_disclosure": "Vous discutez avec un assistant automatisé, pas avec une personne.", + "other": "autre", + "others": "autres", + "list_separator": ", ", + "list_conjunction": " et ", + "language_switcher_aria": "Langue", + "cookie_consent_aria": "Consentement aux cookies", + "cookie_text": "Nous utilisons des cookies analytiques pour comprendre l'utilisation de cette page de réservation; ils ne sont activés qu'avec votre accord.", + "privacy_policy": "Politique de confidentialité", + "decline": "Refuser", + "accept": "Accepter", + "privacy": "Confidentialité", + "terms": "Conditions", + "cookie_settings": "Paramètres des cookies", + "prev_month_aria": "Mois précédent", + "next_month_aria": "Mois suivant", + "demo_public_label": "Démo publique", + "demo_public_text": "les renseignements saisis ici sont visibles par tous et sont réinitialisés automatiquement. N'y saisissez aucun renseignement personnel.", + "demo_view_source": "Voir le code source", + "powered_by": "Propulsé par", + "duration_min": "%d min", + "duration_hour_one": "1 heure", + "duration_hours": "%d heures", + "duration_hr_min": "%d h %d min", + "location_video_meeting": "Visioconférence", + "location_phone": "Appel téléphonique", + "location_in_person": "En personne", + "location_video_call": "Visioconférence", + "manage_booking_title": "Gérer la réservation", + "link_expired_title": "Lien expiré ou invalide", + "link_expired_text": "Ce lien de gestion a expiré ou n'est plus valide. Consultez votre courriel de confirmation pour un lien valide, ou contactez l'organisateur pour en obtenir un nouveau.", + "booking_already_cancelled_title": "Réservation déjà annulée", + "booking_already_cancelled_text": "Cette réservation a été annulée. Pour réserver un nouveau créneau, rendez-vous sur la page de l'événement.", + "scheduled_for": "Prévu le", + "reschedule": "Reporter", + "cancel_booking": "Annuler la réservation", + "select_a_date": "Sélectionnez une date", + "pick_day_hint": "Choisissez un jour dans le calendrier.", + "confirm_reschedule_question": "Confirmer le report?", + "was_label": "Avant", + "now_label": "Maintenant", + "confirm_reschedule": "Confirmer le report", + "rescheduling": "Report en cours...", + "cancel_this_booking_question": "Annuler cette réservation?", + "cancel_booking_notice": "Cette action est irréversible. Un courriel d'annulation vous sera envoyé, ainsi qu'à l'organisateur.", + "reason_optional_label": "Motif (facultatif)", + "reason_placeholder": "ex. conflit d'horaire", + "yes_cancel_booking": "Oui, annuler la réservation", + "cancelling": "Annulation...", + "booking_rescheduled_title": "Réservation reportée", + "booking_rescheduled_prefix": "Votre réservation a été déplacée au ", + "booking_cancelled_title": "Réservation annulée", + "booking_cancelled_text": "Votre réservation a été annulée.", + "slot_no_longer_available_error": "Ce créneau n'est plus disponible. Veuillez en choisir un autre.", + "reschedule_failed_error": "Le report a échoué. Veuillez réessayer.", + "already_cancelled_error": "Cette réservation est déjà annulée.", + "cancellation_failed_error": "L'annulation a échoué. Veuillez réessayer.", + "dow_short_sun": "dim.", + "dow_short_mon": "lun.", + "dow_short_tue": "mar.", + "dow_short_wed": "mer.", + "dow_short_thu": "jeu.", + "dow_short_fri": "ven.", + "dow_short_sat": "sam.", + "month_short_jan": "janv.", + "month_short_feb": "févr.", + "month_short_mar": "mars", + "month_short_apr": "avr.", + "month_short_may": "mai", + "month_short_jun": "juin", + "month_short_jul": "juill.", + "month_short_aug": "août", + "month_short_sep": "sept.", + "month_short_oct": "oct.", + "month_short_nov": "nov.", + "month_short_dec": "déc.", + "clock_format": "24h", + "date_format": "%[1]s %[2]d %[3]s %[4]d", + "email_greeting": "Bonjour %s,", + "email_confirmed_lead": "Votre réservation est confirmée.", + "email_cancelled_lead": "Votre réservation a été annulée.", + "email_rescheduled_lead": "Votre réservation a été reportée.", + "email_reminder_lead": "Ceci est un rappel : votre rendez-vous approche.", + "email_label_event": "Événement", + "email_label_with": "Avec", + "email_label_start": "Début", + "email_label_end": "Fin", + "email_label_when": "Quand", + "email_label_location": "Lieu", + "email_label_reason": "Motif", + "email_label_was": "Avant", + "email_label_now": "Maintenant", + "email_add_to_calendar": "Ajoutez à votre calendrier :", + "email_add_to_calendar_updated": "Ajoutez à votre calendrier (horaire mis à jour) :", + "email_calendar_google": "Google Calendar", + "email_calendar_outlook": "Outlook", + "email_booking_reference": "Référence de la réservation : %s", + "email_manage_link_intro": "Pour reporter ou annuler, rendez-vous sur :", + "email_manage_link_intro_reschedule": "Pour reporter ou annuler à nouveau, rendez-vous sur :", + "email_cancel_link_intro": "Pour annuler, rendez-vous sur :", + "email_rebook_intro": "Pour réserver à nouveau, rendez-vous sur :", + "email_manage_button": "Gérer la réservation", + "email_rebook_button": "Réserver à nouveau", + "email_subject_confirmed": "Réservation confirmée : %s", + "email_subject_cancelled": "Réservation annulée : %s", + "email_subject_rescheduled": "Réservation reportée : %s", + "email_subject_reminder": "Rappel : %s approche", + "email_booking_with": "Réservation avec %s", + "email_manage_this_booking": "Gérer cette réservation :", + "calendar_event_summary": "%s avec %s", + "calendar_event_booking_id": "Référence de la réservation : %s", + "err_booking_limit": "Vous avez déjà atteint le nombre maximum de réservations à venir pour cet événement. Annulez une réservation existante ou attendez qu'elle soit passée avant d'en créer une nouvelle.", + "err_email_throttled": "Trop de réservations récentes depuis cette adresse courriel. Veuillez réessayer plus tard.", + "err_required_field": "le champ obligatoire %q est manquant", + "err_required_checkbox": "%q doit être coché", + "err_invalid_option": "option non valide pour %[1]q : %[2]q n'est pas un choix autorisé", + "assistant_unavailable": "L'assistant de réservation n'est pas disponible pour le moment. Choisissez un créneau dans le calendrier ci-dessous.", + "assistant_trouble": "Désolé, nous rencontrons un problème. Choisissez un créneau dans le calendrier ci-dessous.", + "assistant_iteration_cap": "Simplifions les choses : choisissez un créneau dans le calendrier ci-dessous.", + "assistant_status_slots": "Vérification des disponibilités...", + "assistant_status_book": "Réservation...", + "assistant_status_working": "En cours..." +}

AltStyle によって変換されたページ (->オリジナル) /