diff --git a/docs/configuration.md b/docs/configuration.md index 35218d95..c2a1f860 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -148,6 +148,25 @@ package, and `raco rivet verify` behavior identical on all three platforms. Projects created before this feature remain valid because all three settings are optional. To embed a Windows icon in an older generated host, add Rivet's conditional `RIVET_WINDOWS_ICON_RC` `ResourceCompile` item from the current host template or regenerate the host project while preserving application UI sources. +## User-visible strings: shared/i18n + +Projects that show user-facing strings keep one flat single source: +`shared/i18n/zh.json` and `shared/i18n/en.json`, each a flat +`string -> string` JSON object with identical key sets. Run +`raco rivet i18n` to verify that parity contract and regenerate the +embedded host tables (`windows/GeneratedI18n.hpp`, +`linux/GeneratedI18n.hpp`, and +`macos-host/Sources/RivetHost/GeneratedI18n.swift`). Hosts look a key up +through their shipped loaders (`rivet_app::i18n::tr` on Windows and +Linux, `GeneratedI18n.tr` on macOS): the active language, then the other +language, then the key itself — so a missing or untranslated key can +never silently render as another host's hardcoded copy. Stage the +`shared/i18n` directory through `resources` when other tooling reads the +raw JSON at runtime; the embedded tables are the authoritative runtime +source. `raco rivet doctor --release` fails when the parity contract +breaks or the embedded tables drift from the shared source, and the +command exits nonzero on any parity violation. + ## Deployment targets - `macos-min-version` — two- or three-component macOS deployment version, for example `14.0` or `14.1`. The backwards-compatible default is `14.0`. diff --git a/platform/linux/host/src/i18n.hpp b/platform/linux/host/src/i18n.hpp new file mode 100644 index 00000000..ed2642f8 --- /dev/null +++ b/platform/linux/host/src/i18n.hpp @@ -0,0 +1,31 @@ +#pragma once + +// Single-source string lookup (issue #242). GeneratedI18n.hpp carries the +// embedded table regenerated from shared/i18n/{zh,en}.json by +// `raco rivet i18n`; tr() consults the active language, then the other +// language, then the key, mirroring the Swift loader byte for byte. +#include + +#include "GeneratedI18n.hpp" + +namespace rivet_app::i18n { + +inline std::string_view tr(std::string_view language, std::string_view key) { + for (auto const& entry : kI18nTable) { + if (entry.key != key) { + continue; + } + auto const& active = (language == "zh") ? entry.zh : entry.en; + if (!active.empty()) { + return active; + } + auto const& other = (language == "zh") ? entry.en : entry.zh; + if (!other.empty()) { + return other; + } + break; + } + return key; +} + +} // namespace rivet_app::i18n diff --git a/platform/windows/host/i18n.hpp b/platform/windows/host/i18n.hpp new file mode 100644 index 00000000..ed2642f8 --- /dev/null +++ b/platform/windows/host/i18n.hpp @@ -0,0 +1,31 @@ +#pragma once + +// Single-source string lookup (issue #242). GeneratedI18n.hpp carries the +// embedded table regenerated from shared/i18n/{zh,en}.json by +// `raco rivet i18n`; tr() consults the active language, then the other +// language, then the key, mirroring the Swift loader byte for byte. +#include + +#include "GeneratedI18n.hpp" + +namespace rivet_app::i18n { + +inline std::string_view tr(std::string_view language, std::string_view key) { + for (auto const& entry : kI18nTable) { + if (entry.key != key) { + continue; + } + auto const& active = (language == "zh") ? entry.zh : entry.en; + if (!active.empty()) { + return active; + } + auto const& other = (language == "zh") ? entry.en : entry.zh; + if (!other.empty()) { + return other; + } + break; + } + return key; +} + +} // namespace rivet_app::i18n diff --git a/rivet-cli/doctor.rkt b/rivet-cli/doctor.rkt index 58f0755f..7069ac96 100644 --- a/rivet-cli/doctor.rkt +++ b/rivet-cli/doctor.rkt @@ -8,6 +8,7 @@ racket/string racket/system "codegen.rkt" + "i18n.rkt" "project.rkt" "runtime.rkt" "windows-tools.rkt") @@ -554,7 +555,26 @@ "installed rivet release is discoverable" (lambda () (and (not installed-version) - "could not read the installed rivet package's release version; install rivet as a package (raco pkg install) before releasing"))))) + "could not read the installed rivet package's release version; install rivet as a package (raco pkg install) before releasing"))) + ;; Projects that keep a shared/i18n single source gate its parity and + ;; the freshness of the embedded host tables (issue #242). Projects + ;; without one skip this check. + (release-check + "i18n tables are consistent and fresh" + (lambda () + (or (check-i18n-parity! project) + (let ([result (check-generated-i18n project)]) + (and result + (or (pair? (hash-ref result 'stale)) + (pair? (hash-ref result 'missing))) + (format "~a generated i18n table~a differ from the shared source or are missing; run `raco rivet i18n`" + (+ (length (hash-ref result 'stale)) + (length (hash-ref result 'missing))) + (if (<= (+ (length (hash-ref result 'stale)) + (length (hash-ref result 'missing))) + 1) + "" + "s"))))))))) (hash 'checks checks 'failed (length (filter (lambda (check) (string=? (hash-ref check 'status) "fail")) checks)))) diff --git a/rivet-cli/i18n.rkt b/rivet-cli/i18n.rkt new file mode 100644 index 00000000..9a3cb271 --- /dev/null +++ b/rivet-cli/i18n.rkt @@ -0,0 +1,232 @@ +#lang racket/base + +;; Single-source user-visible strings (issue #242). Projects keep one flat +;; `shared/i18n/{zh,en}.json` pair; this module verifies the parity contract +;; (both files are flat string->string maps with identical key sets) and +;; regenerates the per-platform embedded tables the host loaders consume, so +;; a key can never exist in one host's fallback and not another's — the +;; fulcrum win/linux drift that motivated this command. + +(require json + racket/file + racket/format + racket/list + racket/string + "project.rkt") + +(provide load-i18n-table + check-i18n-parity! + i18n-outputs + generate-i18n! + check-generated-i18n + cpp-string-view-literal + swift-string-literal-value) + +(define i18n-languages '(zh en)) + +(define (language-file project language) + (project-path project "shared" "i18n" (format "~a.json" language))) + +;; Reads one language file and enforces the flat string->string shape. The +;; error messages name the file and the offending key so a project with a +;; hand-grown nested structure can find the exact spot to flatten. +(define (read-language-table! project language) + ;; read-json returns JSON object keys as symbols; normalize them to + ;; strings because the table's contract — and the generated tables — are + ;; string-keyed. + (define path (language-file project language)) + (unless (file-exists? path) + (error 'i18n "missing ~a table; expected ~a" language path)) + (define table + (with-handlers ([exn:fail? + (lambda (e) + (error 'i18n "~a is not valid JSON: ~a" path (exn-message e)))]) + (call-with-input-file path read-json))) + (unless (hash? table) + (error 'i18n "~a must be a flat JSON object" path)) + (for/hash ([(key value) (in-hash table)]) + (unless (or (string? key) (symbol? key)) + (error 'i18n "~a key ~a is not a string" path key)) + (unless (string? value) + (error 'i18n "~a value for ~a is not a string" path key)) + (values (if (symbol? key) (symbol->string key) key) value))) + +;; Returns an association list of (key . (zh-value en-value)) sorted by key, +;; raising when the parity contract fails. +(define (load-i18n-table project) + (define tables + (for/hasheq ([language (in-list i18n-languages)]) + (values language (read-language-table! project language)))) + (define keys + (sort (remove-duplicates + (append* (for/list ([language (in-list i18n-languages)]) + (hash-keys (hash-ref tables language))))) + stringstring n 16)) + (if (< (string-length digits) 2) + (string-append "0" digits) + digits)) + +(define (cpp-string-view-literal text) + ;; Every byte outside the printable ASCII range becomes a three-digit + ;; octal escape over the UTF-8 encoding: fixed width, so consecutive + ;; escapes cannot run into each other (unlike \xNN, whose greedy hex + ;; digits force string splits and cannot exceed one byte anyway). + (string-append + "\"" + (string-append* + (for/list ([byte (in-list (bytes->list (string->bytes/utf-8 text)))]) + (cond + [(= byte 10) "\\n"] + [(= byte 13) "\\r"] + [(= byte 9) "\\t"] + [(= byte 34) "\\\""] + [(= byte 92) "\\\\"] + [(and (>= byte 32) (<= byte 126)) + (string (integer->char byte))] + [else + (format "\\~o" byte)]))) + "\"")) + +(define (swift-string-literal-value text) + ;; Swift sources are UTF-8 by default: keep non-ASCII readable, escape + ;; only quotes, backslashes, and C0 control characters. + (string-append + "\"" + (regexp-replace* #px"[\"\\\\]|[\x00-\x1f]" + text + (lambda (match) + (define char (string-ref match 0)) + (case char + [(#\") "\\\""] + [(#\\) "\\\\"] + [(#\nul) "\\0"] + [else + (format "\\u{~a}" + (hex-2 (char->integer char)))]))) + "\"")) + +(define (cpp-i18n-header table) + (string-append + "// Generated by Rivet. Do not edit by hand.\n" + "#pragma once\n\n#include \n#include \n#include \n\n" + "namespace rivet_app {\n\n" + "// One row per user-visible key: the key plus the zh and en strings from\n" + "// the project's shared/i18n single source. The host loader consults the\n" + "// active language, falls back to the other language, then to the key.\n" + "struct I18nEntry {\n std::string_view key;\n std::string_view zh;\n std::string_view en;\n};\n\n" + (format "inline constexpr std::array kI18nTable{{\n" + (length table)) + (string-join + (for/list ([row (in-list table)]) + (format " {~a, ~a, ~a}" + (cpp-string-view-literal (car row)) + (cpp-string-view-literal (list-ref (cdr row) 0)) + (cpp-string-view-literal (list-ref (cdr row) 1)))) + ",\n") + "\n}};\n\n} // namespace rivet_app\n")) + +(define (swift-i18n-source table) + (string-append + "// Generated by Rivet. Do not edit by hand.\n" + "import Foundation\n\n" + "// One row per user-visible key from the project's shared/i18n single\n" + "// source; tr() consults the active language, the other language, then the\n" + "// key, mirroring the C++ loaders byte for byte.\n" + "enum GeneratedI18n {\n" + " static let table: [String: [String: String]] = [\n" + (string-join + (for/list ([row (in-list table)]) + (format " ~a: [\"zh\": ~a, \"en\": ~a]" + (swift-string-literal-value (car row)) + (swift-string-literal-value (list-ref (cdr row) 0)) + (swift-string-literal-value (list-ref (cdr row) 1)))) + ",\n") + ",\n ]\n\n" + " static func tr(_ language: String, _ key: String) -> String {\n" + " guard let row = table[key] else { return key }\n" + " let active = row[language] ?? \"\"\n" + " if !active.isEmpty { return active }\n" + " let other = row[language == \"zh\" ? \"en\" : \"zh\"] ?? \"\"\n" + " if !other.isEmpty { return other }\n" + " return key\n" + " }\n" + "}\n")) + +(define (write-generated! path content) + (make-parent-directory* path) + (call-with-output-file path #:exists 'truncate/replace + (lambda (out) (display content out)))) + +;; Computes the embedded tables as (path . content) pairs without touching +;; the disk — the same plan/writer split as codegen's client-outputs, so the +;; release gate and the writer can never disagree about a fresh tree. +(define (i18n-outputs project) + (define table (load-i18n-table project)) + (when (null? table) + (error 'i18n "the i18n tables are empty; add keys to shared/i18n")) + (define outputs + (list* + (cons (project-path project "macos-host" "Sources" "RivetHost" + "GeneratedI18n.swift") + (swift-i18n-source table)) + (cons (project-path project "windows" "GeneratedI18n.hpp") + (cpp-i18n-header table)) + (let ([linux-host (project-path project "linux")]) + (if (directory-exists? linux-host) + (list (cons (build-path linux-host "GeneratedI18n.hpp") + (cpp-i18n-header table))) + '())))) + (cons outputs (length table))) + +(define (generate-i18n! project) + (define plan (i18n-outputs project)) + (for ([pair (in-list (car plan))]) + (write-generated! (car pair) (cdr pair))) + (list (cdr plan) (length (car plan)))) + +;; The release-gate variant: returns the stale/missing lists, or #f when the +;; project keeps no shared/i18n directory (nothing to gate). +(define (check-generated-i18n project) + (define shared-dir (project-path project "shared" "i18n")) + (and (directory-exists? shared-dir) + (let ([outputs (car (i18n-outputs project))]) + (define stale '()) + (define missing '()) + (for ([pair (in-list outputs)]) + (cond + [(not (file-exists? (car pair))) + (set! missing (cons (car pair) missing))] + [(not (string=? (file->string (car pair)) (cdr pair))) + (set! stale (cons (car pair) stale))])) + (if (and (null? stale) (null? missing)) + #f + (hash 'stale (reverse stale) 'missing (reverse missing)))))) diff --git a/rivet-cli/main.rkt b/rivet-cli/main.rkt index 6ccca21c..2ea0d931 100644 --- a/rivet-cli/main.rkt +++ b/rivet-cli/main.rkt @@ -63,6 +63,7 @@ " [--sidecars] [--minimum-version ] [--previous-version ]\n" " [--published-at ] [--rollout <0-100>] [--rollback-allowed]\n" " compose a verified family update manifest\n" + " raco rivet i18n verify shared/i18n parity and regenerate embedded tables\n" " raco rivet compliance generate SBOM/notices and run the license audit\n" " raco rivet verify re-verify the current packaged artifact\n" " raco rivet verify --production verify production trust/notarization requirements\n" @@ -287,6 +288,13 @@ (say "production trust verified ~a~a" output (if launch-smoke? "" " (launch smoke skipped)"))] + [(list "i18n") + ;; Single-source user-visible strings (issue #242): verify parity of + ;; shared/i18n/{zh,en}.json and regenerate the embedded host tables. + (define generate-i18n! (command "i18n" 'generate-i18n!)) + (define-values (keys files) (apply values (generate-i18n! (current-project!)))) + (say "i18n: ~a keys across zh/en; wrote ~a embedded table~a" + keys files (if (= files 1) "" "s"))] [(list "compliance") (define generate-compliance-artifacts! (command "compliance" 'generate-compliance-artifacts!)) diff --git a/rivet/tests/i18n.rkt b/rivet/tests/i18n.rkt new file mode 100644 index 00000000..2f6a8582 --- /dev/null +++ b/rivet/tests/i18n.rkt @@ -0,0 +1,152 @@ +#lang racket/base + +(require json + racket/file + racket/list + racket/path + racket/port + racket/runtime-path + racket/string + racket/system + rackunit + "../../rivet-cli/i18n.rkt" + "../../rivet-cli/project.rkt" + "../../rivet-cli/scaffold.rkt") + +(define-runtime-path repository-root "../..") + +(define temp-root (make-temporary-file "rivet-i18n-~a" 'directory)) +(dynamic-wind + void + (lambda () + (define project-root (create-project! "localized" temp-root)) + (define (write-table! language content) + (make-directory* (build-path project-root "shared" "i18n")) + (call-with-output-file + (build-path project-root "shared" "i18n" + (string-append language ".json")) + #:exists 'truncate/replace + (lambda (out) (display content out)))) + + ;; Missing tables are a hard error with a file-naming message. + (check-exn #rx"missing zh table" + (lambda () (generate-i18n! (load-project project-root)))) + (write-table! "zh" "{\"app.title\": \"示例\"}") + (check-exn #rx"missing en table" + (lambda () (generate-i18n! (load-project project-root)))) + + ;; The healthy path: flat zh/en maps with identical key sets regenerate + ;; every embedded table, escapes intact. + (write-table! + "en" + "{\"app.title\": \"Demo\", \"app.quit\": \"Quit demo\", \"weird\": \"quote\\\" back\\\\ tick\"}") + (write-table! + "zh" + "{\"app.title\": \"示例\", \"app.quit\": \"退出\", \"weird\": \"引号\\\" 反斜杠\\\\\"}") + (define project (load-project project-root)) + (define-values (keys files) + (apply values (generate-i18n! project))) + (check-equal? keys 3) + (check-equal? files 3) + + (define cpp-table + (file->string (build-path project-root "windows" "GeneratedI18n.hpp"))) + (define linux-table + (file->string (build-path project-root "linux" "GeneratedI18n.hpp"))) + (define swift-table + (file->string + (build-path project-root "macos-host" "Sources" "RivetHost" + "GeneratedI18n.swift"))) + (check-true (string-contains? cpp-table "kI18nTable")) + (check-true (string-contains? cpp-table "\"app.title\"")) + (check-true (string-contains? cpp-table "\"\\347\\244\\272\\344\\276\\213") + "non-ASCII content escapes into UTF-8 octal bytes") + (check-true (string-contains? cpp-table "quote\\\" back\\\\ tick")) + (check-equal? cpp-table linux-table) + (check-true (string-contains? swift-table "enum GeneratedI18n")) + (check-true (string-contains? swift-table "\"app.quit\"")) + (check-true (string-contains? swift-table "\"退出\"")) + + ;; The generated C++ table is real C++: compile it with the shipped + ;; loader and check tr()'s lookup order (active → other → key). + (define generated-header + (build-path project-root "windows" "GeneratedI18n.hpp")) + (define loader-header + (build-path repository-root "platform" "windows" "host" "i18n.hpp")) + (define probe-source + (build-path temp-root "tr-probe.cpp")) + (call-with-output-file probe-source + (lambda (out) + (displayln #< +#include + +int main() { + using namespace rivet_app::i18n; + // active language wins + assert(std::string(tr("zh", "app.title")) == "\xE7\xA4\xBA\xE4\xBE\x8B"); + assert(std::string(tr("en", "app.title")) == "Demo"); + // active language missing falls back to the other language + assert(std::string(tr("zh", "app.quit")) == "\xE9\x80\x80\xE5\x87\xBA"); + // unknown keys return the key itself + assert(std::string(tr("zh", "nope")) == "nope"); + return 0; +} +CPP + out)) + #:exists 'truncate) + (define compiler + (or (find-executable-path "c++") (find-executable-path "g++") + (find-executable-path "clang++"))) + (when compiler + (define compile-exit + (parameterize ([current-output-port (open-output-nowhere)] + [current-error-port (open-output-nowhere)]) + (system* compiler "-std=c++20" + (format "-I~a" (path-only generated-header)) + (format "-I~a" (path-only loader-header)) + (path->string probe-source) + "-o" (path->string (build-path temp-root "tr-probe"))))) + (check-true (and compile-exit #t) "tr() probe must compile") + (when compile-exit + (check-equal? + (parameterize ([current-output-port (open-output-nowhere)] + [current-error-port (open-output-nowhere)]) + (system* (build-path temp-root "tr-probe"))) + #t))) + + ;; Freshness gate: regenerate once, then tamper — the release-gate check + ;; must see stale and missing files until the writer repairs them. + (check-false (hash? (check-generated-i18n project)) + "a fresh tree reports no findings") + (define linux-table-path + (build-path project-root "linux" "GeneratedI18n.hpp")) + (call-with-output-file generated-header + #:exists 'truncate + (lambda (out) (display "// hand-edited\n" out))) + (define tampered (check-generated-i18n project)) + (check-equal? (hash-ref tampered 'stale) (list generated-header)) + (delete-file linux-table-path) + (define tampered-more (check-generated-i18n project)) + (check-equal? (length (hash-ref tampered-more 'missing)) 1) + (void (generate-i18n! project)) + (check-false (hash? (check-generated-i18n project))) + + ;; Parity failures for doctor: non-flat structures and drifted key sets + ;; both surface as messages, and a project without shared/i18n skips. + (write-table! "en" "{\"nested\": {\"key\": \"value\"}}") + (write-table! "zh" "{\"nested\": {\"key\": \"值\"}}") + (define nested-report (check-i18n-parity! project)) + (check-true (and (string? nested-report) + (string-contains? nested-report "is not a string"))) + (write-table! "en" "{\"a\": \"1\"}") + (write-table! "zh" "{\"b\": \"2\"}") + (define drifted-report (check-i18n-parity! project)) + (check-true (and (string? drifted-report) + (string-contains? drifted-report "parity check failed"))) + (delete-directory/files (build-path project-root "shared")) + (check-false (check-i18n-parity! project))) + (lambda () + (when (directory-exists? temp-root) + (delete-directory/files temp-root))))