feat(i18n): localize shared-task artifacts in the creator's locale (#2147)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-02 10:58:18 +02:00
committed by GitHub
parent 4675391626
commit c7be985a29
19 changed files with 940 additions and 136 deletions
+1 -1
View File
@@ -296,5 +296,5 @@ golangci-lint run ./... --new-from-rev="<base-sha>" --timeout=5m
- `internal/agentctl/AGENTS.md` — agentctl server route groups, adapter model, ACP protocol
- `internal/agentctl/server/api/AGENTS.md` — reverse-proxy body rewriting (`Accept-Encoding`), iframe-blocking header stripping
- `internal/integrations/AGENTS.md` — playbook for adding a new third-party integration (Jira/Linear pattern)
- `docs/i18n.md` ("Backend") — `internal/i18n` covers only what Go renders straight to a browser (today: the SPA-unavailable error pages). Everything else stays English by design; for new user-facing output prefer a stable error code the frontend translates.
- `docs/i18n.md` ("Backend") — `internal/i18n` covers only what Go renders straight to a browser: the SPA-unavailable error pages and the shared-task artifacts (`share.html`, gist README and description). Both are complete. Everything else stays English by design; for new user-facing output prefer a stable error code the frontend translates. Use `i18n.Tf` for anything carrying a value — never `fmt.Sprintf` a translated string, and never build a plural in Go. A locale for output that outlives the request is resolved once at write time and threaded as an argument, not a context value (ADR `2026-08-01-share-artifact-locale.md`).
- `cmd/mock-agent/AGENTS.md` — predefined `/e2e:<name>` scenarios vs inline `e2e:...` scripts, recipe for adding a scenario, and the rebuild-before-e2e requirement
+95 -1
View File
@@ -7,9 +7,15 @@
// harder without helping a user. See docs/i18n.md ("Backend").
//
// What this package covers is the surface a user reads straight from Go: the
// plain-text error pages served when the SPA bundle cannot be delivered, and any
// plain-text error pages served when the SPA bundle cannot be delivered, the
// shared-task artifacts (share.html, the gist README and description), and any
// future server-rendered copy.
//
// A shared task is a static file on GitHub — nobody makes a request to us when
// they open it — so its locale is fixed when the share is created, from the
// creator's request, and threaded down to the builders as an argument. See
// docs/decisions/2026-08-01-share-artifact-locale.md.
//
// Catalogs mirror the frontend's shape (`locales/<locale>.json`, flat
// `key: message`) and are embedded, so no runtime file access is needed.
package i18n
@@ -167,6 +173,94 @@ func T(locale, key string) string {
return key
}
// Tf returns the message for key in locale with every `{{name}}` placeholder
// replaced by the matching entry in vars. It is the only supported way to build
// a message that carries a value: `fmt.Sprintf` on the result of T would freeze
// the argument order in English, and a translator cannot move a placeholder that
// is not in the catalog.
//
// When vars carries a "count", the i18next plural suffixes are resolved first —
// `key_one` for exactly 1, `key_other` for anything else — falling back to the
// unsuffixed key when the catalog defines no plural forms. That keeps the
// contract identical to the frontend's `t("key", { count })` and is why no call
// site ever appends an English "s" itself.
//
// Values are substituted verbatim; escaping (HTML, Markdown) belongs to the call
// site, which is the only place that knows the target syntax.
func Tf(locale, key string, vars map[string]any) string {
return interpolate(T(locale, pluralKey(locale, key, vars)), vars)
}
// pluralKey picks the plural variant of key that the catalog actually defines,
// so a message with only a base form still resolves.
func pluralKey(locale, key string, vars map[string]any) string {
count, ok := countVar(vars)
if !ok {
return key
}
// Only an exact 1 is singular. Comparing the original value rather than a
// truncated integer is what stops 1.5 from rendering as "1.5 message".
suffixed := key + "_other"
if count == 1 {
suffixed = key + "_one"
}
// T echoes the key back when it is missing — that is how we detect a catalog
// that never defined the plural forms.
if T(locale, suffixed) != suffixed {
return suffixed
}
return key
}
// countVar reads the plural selector out of vars as a float64, which is wide
// enough for every Go numeric type a caller might hold (a proto int32, a store
// aggregate uint64, a JSON-decoded float64) and preserves the fractional part
// that decides singular-vs-plural. Non-numeric values are ignored rather than
// erroring: a bad "count" degrades to the unsuffixed key.
func countVar(vars map[string]any) (float64, bool) {
switch n := vars["count"].(type) {
case int:
return float64(n), true
case int8:
return float64(n), true
case int16:
return float64(n), true
case int32:
return float64(n), true
case int64:
return float64(n), true
case uint:
return float64(n), true
case uint8:
return float64(n), true
case uint16:
return float64(n), true
case uint32:
return float64(n), true
case uint64:
return float64(n), true
case float32:
return float64(n), true
case float64:
return n, true
}
return 0, false
}
// interpolate replaces `{{name}}` with vars[name]. Placeholders with no matching
// var are left in place, which makes the omission visible instead of silently
// producing a sentence with a hole in it.
func interpolate(message string, vars map[string]any) string {
if len(vars) == 0 || !strings.Contains(message, "{{") {
return message
}
pairs := make([]string, 0, len(vars)*2)
for name, value := range vars {
pairs = append(pairs, "{{"+name+"}}", fmt.Sprint(value))
}
return strings.NewReplacer(pairs...).Replace(message)
}
// Keys lists every key in the source catalog; used by the drift test that keeps
// translations in step with `en`.
func Keys() []string {
+67
View File
@@ -3,6 +3,7 @@ package i18n
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -42,6 +43,72 @@ func TestTranslatesAndFallsBack(t *testing.T) {
}
}
func TestTf(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
locale string
key string
vars map[string]any
want string
}{
{"interpolates a placeholder", "en", "share.pageTitle",
map[string]any{"title": "Fix the flake"}, "Fix the flake — kandev share"},
{"count of one selects the singular", "en", "share.messageCount",
map[string]any{"count": 1}, "1 message"},
{"count of zero selects the plural", "en", "share.messageCount",
map[string]any{"count": 0}, "0 messages"},
{"count of many selects the plural", "en", "share.messageCount",
map[string]any{"count": 12}, "12 messages"},
// JSON-decoded numbers arrive as float64; they must still select a form.
{"float count selects a form", "en", "share.messageCount",
map[string]any{"count": float64(1)}, "1 message"},
// Only an exact 1 is singular. Truncating to an int would pick the
// singular here and render "1.5 message".
{"fractional count is plural", "en", "share.messageCount",
map[string]any{"count": 1.5}, "1.5 messages"},
// Widths other than int reach Tf from proto fields and store aggregates;
// falling through to the base key would render the plural for every value.
{"int32 count selects the singular", "en", "share.messageCount",
map[string]any{"count": int32(1)}, "1 message"},
{"uint64 count selects the plural", "en", "share.messageCount",
map[string]any{"count": uint64(4)}, "4 messages"},
// A key with no _one/_other entries must not resolve to a missing
// suffixed key and echo it back — it falls back to the base message.
{"key without plural forms falls back", "en", "share.untitledTask",
map[string]any{"count": 3}, "Untitled task"},
// A placeholder with no value stays visible; a silent hole in a
// sentence is much harder to notice in review than "{{title}}".
{"missing var leaves the placeholder", "en", "share.pageTitle",
nil, "{{title}} — kandev share"},
{"unsupported locale falls back to en", "klingon", "share.messageCount",
map[string]any{"count": 2}, "2 messages"},
{"non-numeric count is ignored", "en", "share.untitledTask",
map[string]any{"count": "many"}, "Untitled task"},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := Tf(tc.locale, tc.key, tc.vars); got != tc.want {
t.Fatalf("Tf(%q, %q, %v) = %q, want %q", tc.locale, tc.key, tc.vars, got, tc.want)
}
})
}
}
func TestTfPseudoLocaleKeepsPlaceholderValues(t *testing.T) {
t.Parallel()
// The pseudo catalog must translate the message but leave the interpolated
// value alone — a transliterated URL or filename would be a dead pointer.
got := Tf("pseudo", "share.messageCount", map[string]any{"count": 7})
if !strings.Contains(got, "7") {
t.Fatalf("pseudo render %q dropped the interpolated count", got)
}
if got == Tf("en", "share.messageCount", map[string]any{"count": 7}) {
t.Fatalf("pseudo render is identical to en: %q", got)
}
}
func TestCatalogsHaveMatchingKeys(t *testing.T) {
t.Parallel()
// Every en key must exist in pseudo, otherwise the QA locale silently shows
@@ -1,4 +1,35 @@
{
"share.brandTag": "shared task",
"share.builtWith": "Built with",
"share.ctaTryKandev": "Try kandev on GitHub",
"share.emptyMessage": "empty",
"share.gistDescription": "kandev share: {{title}}",
"share.gistDescriptionUntitled": "kandev task share",
"share.messageCount_one": "{{count}} message",
"share.messageCount_other": "{{count}} messages",
"share.metaAgent": "Agent",
"share.metaCompleted": "Completed",
"share.metaExecutor": "Executor",
"share.metaModel": "Model",
"share.metaStarted": "Started",
"share.metaWorkflowStep": "Workflow step",
"share.noMessages": "No messages.",
"share.noMetadata": "no metadata",
"share.openRenderedView": "**[Open the rendered view →]({{url}})** for a properly styled conversation.",
"share.openShareHTML": "**Open {{file}}** for the rendered conversation.",
"share.pageTitle": "{{title}} — kandev share",
"share.pitch": "Shared from **[kandev]({{url}})** — the open-source agentic dev environment.",
"share.rawExport": "Raw export:",
"share.redacted": "Redacted:",
"share.redactedBeforePublish": "Redacted before publish:",
"share.roleAssistant": "Assistant",
"share.roleSystem": "System",
"share.roleUser": "User",
"share.roleYou": "You",
"share.sessionDetails": "Session details",
"share.toolOutput": "Tool output",
"share.toolOutputTruncated": "Tool output (truncated)",
"share.untitledTask": "Untitled task",
"webapp.assetReadFailed": "Could not read a web asset.",
"webapp.devServerUnavailable": "The Vite dev server is unavailable.",
"webapp.shellUnavailable": "The Kandev web app is unavailable."
@@ -1,4 +1,35 @@
{
"share.brandTag": "śĥàŕēď ţàśķ",
"share.builtWith": "Ɓũĩĺţ ŵĩţĥ",
"share.ctaTryKandev": "Ţŕŷ ķàńďēv ōń ĜĩţĤũƀ",
"share.emptyMessage": "ēḿƥţŷ",
"share.gistDescription": "ķàńďēv śĥàŕē: {{title}}",
"share.gistDescriptionUntitled": "ķàńďēv ţàśķ śĥàŕē",
"share.messageCount_one": "{{count}} ḿēśśàĝē",
"share.messageCount_other": "{{count}} ḿēśśàĝēś",
"share.metaAgent": "Àĝēńţ",
"share.metaCompleted": "Ćōḿƥĺēţēď",
"share.metaExecutor": "Ēxēćũţōŕ",
"share.metaModel": "Ḿōďēĺ",
"share.metaStarted": "Śţàŕţēď",
"share.metaWorkflowStep": "Ŵōŕķƒĺōŵ śţēƥ",
"share.noMessages": "Ńō ḿēśśàĝēś.",
"share.noMetadata": "ńō ḿēţàďàţà",
"share.openRenderedView": "**[Ōƥēń ţĥē ŕēńďēŕēď vĩēŵ →]({{url}})** ƒōŕ à ƥŕōƥēŕĺŷ śţŷĺēď ćōńvēŕśàţĩōń.",
"share.openShareHTML": "**Ōƥēń {{file}}** ƒōŕ ţĥē ŕēńďēŕēď ćōńvēŕśàţĩōń.",
"share.pageTitle": "{{title}} — ķàńďēv śĥàŕē",
"share.pitch": "Śĥàŕēď ƒŕōḿ **[ķàńďēv]({{url}})** — ţĥē ōƥēń-śōũŕćē àĝēńţĩć ďēv ēńvĩŕōńḿēńţ.",
"share.rawExport": "Ŕàŵ ēxƥōŕţ:",
"share.redacted": "Ŕēďàćţēď:",
"share.redactedBeforePublish": "Ŕēďàćţēď ƀēƒōŕē ƥũƀĺĩśĥ:",
"share.roleAssistant": "Àśśĩśţàńţ",
"share.roleSystem": "Śŷśţēḿ",
"share.roleUser": "Ũśēŕ",
"share.roleYou": "Ŷōũ",
"share.sessionDetails": "Śēśśĩōń ďēţàĩĺś",
"share.toolOutput": "Ţōōĺ ōũţƥũţ",
"share.toolOutputTruncated": "Ţōōĺ ōũţƥũţ (ţŕũńćàţēď)",
"share.untitledTask": "Ũńţĩţĺēď ţàśķ",
"webapp.assetReadFailed": "Ćōũĺď ńōţ ŕēàď à ŵēƀ àśśēţ.",
"webapp.devServerUnavailable": "Ţĥē Vĩţē ďēv śēŕvēŕ ĩś ũńàvàĩĺàƀĺē.",
"webapp.shellUnavailable": "Ţĥē Ķàńďēv ŵēƀ àƥƥ ĩś ũńàvàĩĺàƀĺē."
+5 -1
View File
@@ -25,7 +25,11 @@ type Backend interface {
// Upload publishes the snapshot and returns the backend-specific
// identifier (e.g. gist ID) and the public URL where it can be viewed.
Upload(ctx context.Context, workspaceID string, snap *Snapshot) (externalID, externalURL string, err error)
//
// locale is the share creator's, resolved from the create request. The
// published artifact is a static file, so a reader opening it later makes
// no request we could resolve a locale from — it has to be baked in here.
Upload(ctx context.Context, workspaceID string, snap *Snapshot, locale string) (externalID, externalURL string, err error)
// Delete removes a previously-uploaded snapshot. Backends MAY return a
// "not found" error which the service detects via IsAlreadyGone.
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/i18n"
)
// GistMaxBytes is a conservative cap on a single snapshot.json file. GitHub
@@ -49,7 +50,7 @@ func (b *GistBackend) Name() string { return BackendGitHubGist }
// the rendered view served via gist.githack.com — that's the link users
// share. The gist itself is preserved in the database via the externalID
// so we can still delete it on revoke.
func (b *GistBackend) Upload(ctx context.Context, workspaceID string, snap *Snapshot) (string, string, error) {
func (b *GistBackend) Upload(ctx context.Context, workspaceID string, snap *Snapshot, locale string) (string, string, error) {
body, err := json.MarshalIndent(snap, "", " ")
if err != nil {
return "", "", fmt.Errorf("marshal snapshot: %w", err)
@@ -62,17 +63,17 @@ func (b *GistBackend) Upload(ctx context.Context, workspaceID string, snap *Snap
return "", "", err
}
resp, err := client.CreateGist(ctx, github.CreateGistInput{
Description: gistDescription(snap),
Description: gistDescription(snap, locale),
Public: false,
Files: map[string]github.GistFile{
// share.html sorts first alphabetically in the gist file list,
// which puts the rendered view at the top of the gist page too.
"share.html": {Content: BuildShareHTML(snap)},
"share.html": {Content: BuildShareHTML(snap, locale)},
"snapshot.json": {Content: string(body)},
// README is built with empty renderedURL — the user's primary
// link goes to the rendered view; the README is a fallback for
// folks who land on the gist directly.
"README.md": {Content: BuildGistREADME(snap, "")},
"README.md": {Content: BuildGistREADME(snap, "", locale)},
},
})
if err != nil {
@@ -211,11 +212,13 @@ func (b *GistBackend) clientFor(ctx context.Context, workspaceID string) (github
return b.client, nil
}
func gistDescription(snap *Snapshot) string {
// gistDescription is the one-line summary GitHub shows on the gist page, so
// it is display copy rather than a diagnostic.
func gistDescription(snap *Snapshot, locale string) string {
if snap == nil || snap.Task.Title == "" {
return "kandev task share"
return i18n.T(locale, "share.gistDescriptionUntitled")
}
return "kandev share: " + snap.Task.Title
return i18n.Tf(locale, "share.gistDescription", map[string]any{"title": snap.Task.Title})
}
func nonEmpty(s, fallback string) string {
@@ -8,6 +8,7 @@ import (
"time"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/i18n"
)
type recordingGitHubResolver struct {
@@ -53,7 +54,7 @@ func TestGistBackend_Upload_CreatesSecretGistWithExpectedFiles(t *testing.T) {
mock := github.NewMockClient()
b := NewGistBackend(mock)
id, url, err := b.Upload(context.Background(), "workspace-1", sampleSnapshot())
id, url, err := b.Upload(context.Background(), "workspace-1", sampleSnapshot(), "en")
if err != nil {
t.Fatalf("upload: %v", err)
}
@@ -106,7 +107,7 @@ func TestGistBackend_Upload_RejectsOversizedSnapshot(t *testing.T) {
big := strings.Repeat("x", GistMaxBytes+1)
snap.Messages = []Message{{Role: "user", Blocks: []Block{{Kind: "text", Text: big}}}}
_, _, err := b.Upload(context.Background(), "workspace-1", snap)
_, _, err := b.Upload(context.Background(), "workspace-1", snap, "en")
if err == nil {
t.Fatal("expected ErrSnapshotTooLarge, got nil")
}
@@ -122,7 +123,7 @@ func TestGistBackend_Delete_Success(t *testing.T) {
t.Parallel()
mock := github.NewMockClient()
b := NewGistBackend(mock)
id, _, err := b.Upload(context.Background(), "workspace-1", sampleSnapshot())
id, _, err := b.Upload(context.Background(), "workspace-1", sampleSnapshot(), "en")
if err != nil {
t.Fatalf("upload: %v", err)
}
@@ -157,11 +158,11 @@ func TestGistBackend_ResolvesOwningWorkspaceForEveryOperation(t *testing.T) {
"workspace-2": second,
}}
backend := NewWorkspaceGistBackend(resolver)
firstID, _, err := backend.Upload(context.Background(), "workspace-1", sampleSnapshot())
firstID, _, err := backend.Upload(context.Background(), "workspace-1", sampleSnapshot(), "en")
if err != nil {
t.Fatalf("workspace-1 upload: %v", err)
}
if _, _, err := backend.Upload(context.Background(), "workspace-2", sampleSnapshot()); err != nil {
if _, _, err := backend.Upload(context.Background(), "workspace-2", sampleSnapshot(), "en"); err != nil {
t.Fatalf("workspace-2 upload: %v", err)
}
if err := backend.Delete(context.Background(), "workspace-1", firstID); err != nil {
@@ -180,7 +181,39 @@ func TestGistBackend_FailsClosedWhenResolverReturnsNoClient(t *testing.T) {
clients: map[string]github.Client{},
allowNil: true,
})
if _, _, err := backend.Upload(context.Background(), "workspace-1", sampleSnapshot()); err == nil {
if _, _, err := backend.Upload(context.Background(), "workspace-1", sampleSnapshot(), "en"); err == nil {
t.Fatal("expected upload to fail when the workspace resolver returns no client")
}
}
func TestGistDescription_LocalizedInBothForms(t *testing.T) {
t.Parallel()
titled := &Snapshot{Task: TaskMeta{Title: "Fixture"}}
for _, tc := range []struct {
name string
snap *Snapshot
key string
}{
{"titled", titled, "share.gistDescription"},
{"untitled", &Snapshot{}, "share.gistDescriptionUntitled"},
{"nil snapshot", nil, "share.gistDescriptionUntitled"},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
en := gistDescription(tc.snap, "en")
pseudo := gistDescription(tc.snap, "pseudo")
if en == pseudo {
t.Fatalf("description %q is identical in both locales — it is hardcoded", en)
}
if !strings.Contains(pseudo, i18n.T("pseudo", tc.key)) &&
!strings.HasPrefix(pseudo, strings.TrimSuffix(i18n.T("pseudo", tc.key), "{{title}}")) {
t.Fatalf("pseudo description %q does not come from key %q", pseudo, tc.key)
}
})
}
// The task title is data, not copy: it must survive verbatim.
if got := gistDescription(titled, "pseudo"); !strings.Contains(got, "Fixture") {
t.Fatalf("pseudo description %q dropped or transliterated the task title", got)
}
}
+81 -33
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"html"
"strings"
"github.com/kandev/kandev/internal/i18n"
)
// kandevRepoURL is the public GitHub repo for the project — used in the
@@ -13,18 +15,49 @@ import (
// receives a shared task.
const kandevRepoURL = "https://github.com/kdlbs/kandev"
// Catalog keys the HTML and Markdown builders share. Keys used in exactly
// one place stay inline at the call site; these are here because the same
// copy has to render identically in both artifacts.
const (
keyUntitledTask = "share.untitledTask"
keyNoMessages = "share.noMessages"
keyMessageCount = "share.messageCount"
keyToolOutput = "share.toolOutput"
keyToolOutputTruncated = "share.toolOutputTruncated"
keyRoleAssistant = "share.roleAssistant"
keyRoleSystem = "share.roleSystem"
)
// toolOutputKey picks between the plain and truncated labels. Two whole
// messages rather than a translated "Tool output" with " (truncated)"
// appended: a suffix built in Go is a sentence fragment no translator can
// reorder, and several languages need to.
func toolOutputKey(truncated bool) string {
if truncated {
return keyToolOutputTruncated
}
return keyToolOutput
}
// BuildShareHTML produces a self-contained styled HTML page that renders
// the snapshot as a real chat conversation: user bubbles right-aligned,
// assistant content left-aligned, consecutive same-role messages fused
// into one block, and tool calls collapsed inline rather than rendered
// as their own messages.
func BuildShareHTML(snap *Snapshot) string {
//
// locale is the share creator's, captured when the share was created. The
// page is a static file on GitHub, so there is no request to resolve a
// locale from when a reader opens it later — see the package docs on
// internal/i18n.
func BuildShareHTML(snap *Snapshot, locale string) string {
if snap == nil {
return "<!doctype html><title>kandev share</title>"
}
var b strings.Builder
b.WriteString("<!doctype html>\n<html lang=\"en\">\n")
writeHTMLHead(&b, snap)
// Normalize rather than trust the caller: <html lang> and every lookup
// below must agree on the locale, and i18n.T normalizes internally.
fmt.Fprintf(&b, "<!doctype html>\n<html lang=%q>\n", i18n.Normalize(locale))
writeHTMLHead(&b, snap, locale)
b.WriteString("<body>\n")
// Duplicate the stylesheet at the top of <body> as defense-in-depth: some
// gist-rendering proxies copy only the body content into their own page,
@@ -37,43 +70,55 @@ func BuildShareHTML(snap *Snapshot) string {
b.WriteString("<style>")
b.WriteString(shareCSS)
b.WriteString("</style>\n")
writeHTMLHero(&b, snap)
writeHTMLHero(&b, snap, locale)
b.WriteString("<main class=\"conv\">\n")
writeHTMLConversation(&b, snap.Messages)
writeHTMLConversation(&b, snap.Messages, locale)
b.WriteString("</main>\n")
writeHTMLFooter(&b, snap)
writeHTMLFooter(&b, snap, locale)
b.WriteString("</body>\n</html>\n")
return b.String()
}
func writeHTMLHead(b *strings.Builder, snap *Snapshot) {
title := html.EscapeString(nonEmpty(snap.Task.Title, "Untitled task"))
// resolvedTitle is the escaped page title, falling back to the localized
// "Untitled task". Shared by the <head> and the hero so the fallback and the
// escaping can never drift between them.
func resolvedTitle(snap *Snapshot, locale string) string {
return html.EscapeString(nonEmpty(snap.Task.Title, i18n.T(locale, keyUntitledTask)))
}
func writeHTMLHead(b *strings.Builder, snap *Snapshot, locale string) {
title := resolvedTitle(snap, locale)
fmt.Fprintf(b, "<head>\n<meta charset=\"utf-8\">\n")
fmt.Fprintf(b, "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
fmt.Fprintf(b, "<title>%s — kandev share</title>\n", title)
// The title is escaped before it goes into the message so the catalog
// entry stays a plain template the translator can reorder.
fmt.Fprintf(b, "<title>%s</title>\n", i18n.Tf(locale, "share.pageTitle", map[string]any{"title": title}))
fmt.Fprintf(b, "<meta name=\"description\" content=\"%s\">\n", title)
b.WriteString("<style>")
b.WriteString(shareCSS)
b.WriteString("</style>\n</head>\n")
}
func writeHTMLHero(b *strings.Builder, snap *Snapshot) {
title := html.EscapeString(nonEmpty(snap.Task.Title, "Untitled task"))
func writeHTMLHero(b *strings.Builder, snap *Snapshot, locale string) {
title := resolvedTitle(snap, locale)
b.WriteString("<header class=\"hero\">\n")
b.WriteString("<div class=\"brand\"><a href=\"" + kandevRepoURL + "\" target=\"_blank\" rel=\"noopener\">kandev</a>")
b.WriteString("<span class=\"brand-sep\">·</span><span class=\"brand-tag\">shared task</span></div>\n")
fmt.Fprintf(b, "<span class=\"brand-sep\">·</span><span class=\"brand-tag\">%s</span></div>\n",
html.EscapeString(i18n.T(locale, "share.brandTag")))
fmt.Fprintf(b, "<h1>%s</h1>\n", title)
b.WriteString("<div class=\"badges\">")
writeHTMLBadge(b, snap.Session.AgentType)
writeHTMLBadge(b, snap.Session.Model)
writeHTMLBadge(b, snap.Session.ExecutorType)
writeHTMLBadge(b, fmt.Sprintf("%d messages", len(snap.Messages)))
writeHTMLBadge(b, i18n.Tf(locale, keyMessageCount, map[string]any{"count": len(snap.Messages)}))
if completed := formatPtrTime(snap.Session.CompletedAt); completed != "" {
writeHTMLBadge(b, completed)
}
b.WriteString("</div>\n")
if len(snap.Redaction.AppliedRules) > 0 {
fmt.Fprintf(b, "<p class=\"redaction\">🛡️ Redacted: <code>%s</code></p>\n",
// The emoji is not copy, so it stays in the format string.
fmt.Fprintf(b, "<p class=\"redaction\">🛡️ %s <code>%s</code></p>\n",
html.EscapeString(i18n.T(locale, "share.redacted")),
html.EscapeString(strings.Join(snap.Redaction.AppliedRules, ", ")))
}
b.WriteString("</header>\n")
@@ -115,49 +160,53 @@ func groupMessages(messages []Message) []messageGroup {
return groups
}
func writeHTMLConversation(b *strings.Builder, messages []Message) {
func writeHTMLConversation(b *strings.Builder, messages []Message, locale string) {
groups := groupMessages(messages)
if len(groups) == 0 {
b.WriteString("<p class=\"empty\">No messages.</p>\n")
fmt.Fprintf(b, "<p class=\"empty\">%s</p>\n", html.EscapeString(i18n.T(locale, keyNoMessages)))
return
}
for _, g := range groups {
writeHTMLGroup(b, g)
writeHTMLGroup(b, g, locale)
}
}
func writeHTMLGroup(b *strings.Builder, g messageGroup) {
cls, label, icon := messageRoleAttrs(g.role)
func writeHTMLGroup(b *strings.Builder, g messageGroup, locale string) {
cls, label, icon := messageRoleAttrs(g.role, locale)
fmt.Fprintf(b, "<section class=\"group group-%s\">\n", cls)
fmt.Fprintf(b, "<div class=\"avatar\" aria-hidden=\"true\">%s</div>\n", icon)
b.WriteString("<div class=\"bubble\">\n")
fmt.Fprintf(b, "<div class=\"role\">%s</div>\n", html.EscapeString(label))
for _, block := range g.blocks {
writeHTMLBlock(b, block)
writeHTMLBlock(b, block, locale)
}
b.WriteString("</div>\n</section>\n")
}
func messageRoleAttrs(role string) (cls, label, icon string) {
// messageRoleAttrs returns the CSS class, the display label, and the avatar
// for a role. Only the label is copy: cls is a class name the stylesheet
// keys off, icon is an emoji, and the default branch echoes the raw role
// straight from the message store — all three stay untranslated.
func messageRoleAttrs(role, locale string) (cls, label, icon string) {
switch role {
case roleUser:
return "user", "You", "🧑"
return "user", i18n.T(locale, "share.roleYou"), "🧑"
case roleAssistant:
return "assistant", "Assistant", "🤖"
return "assistant", i18n.T(locale, keyRoleAssistant), "🤖"
case roleSystem:
return "system", "System", "⚙️"
return "system", i18n.T(locale, keyRoleSystem), "⚙️"
}
return "other", role, "•"
}
func writeHTMLBlock(b *strings.Builder, block Block) {
func writeHTMLBlock(b *strings.Builder, block Block, locale string) {
switch block.Kind {
case blockKindText:
writeHTMLText(b, block.Text)
case blockKindToolCall:
writeHTMLToolCall(b, block)
case blockKindToolResult:
writeHTMLToolResult(b, block)
writeHTMLToolResult(b, block, locale)
case blockKindDiff:
writeHTMLDiff(b, block)
}
@@ -182,15 +231,12 @@ func writeHTMLToolCall(b *strings.Builder, block Block) {
b.WriteString("</details>\n")
}
func writeHTMLToolResult(b *strings.Builder, block Block) {
func writeHTMLToolResult(b *strings.Builder, block Block, locale string) {
out := strings.TrimRight(block.Output, "\n")
if strings.TrimSpace(out) == "" {
return
}
label := "Tool output"
if block.Truncated {
label += " (truncated)"
}
label := i18n.T(locale, toolOutputKey(block.Truncated))
b.WriteString("<details class=\"tool tool-result\">\n<summary>")
b.WriteString("<span class=\"tool-icon\">📤</span>")
fmt.Fprintf(b, "<span class=\"tool-name\">%s</span>", html.EscapeString(label))
@@ -228,9 +274,11 @@ func writeHTMLDiffLine(b *strings.Builder, line string) {
fmt.Fprintf(b, "<span class=\"%s\">%s</span>\n", cls, html.EscapeString(line))
}
func writeHTMLFooter(b *strings.Builder, snap *Snapshot) {
func writeHTMLFooter(b *strings.Builder, snap *Snapshot, locale string) {
b.WriteString("<footer class=\"page-footer\">\n")
b.WriteString("<a href=\"" + kandevRepoURL + "\" target=\"_blank\" rel=\"noopener\" class=\"cta\">Try kandev on GitHub →</a>\n")
// The arrow is decoration, not copy — it stays outside the message.
fmt.Fprintf(b, "<a href=\"%s\" target=\"_blank\" rel=\"noopener\" class=\"cta\">%s →</a>\n",
kandevRepoURL, html.EscapeString(i18n.T(locale, "share.ctaTryKandev")))
b.WriteString("<span class=\"foot-sep\">·</span>\n")
b.WriteString("<a href=\"snapshot.json\" class=\"foot-link\">snapshot.json</a>\n")
if snap.KandevVersion != "" {
@@ -5,6 +5,8 @@ import (
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/i18n"
)
func TestBuildShareHTML_RendersChatLayout(t *testing.T) {
@@ -38,7 +40,7 @@ func TestBuildShareHTML_RendersChatLayout(t *testing.T) {
Redaction: RedactionLog{AppliedRules: []string{RuleAbsPath}},
}
doc := BuildShareHTML(snap)
doc := BuildShareHTML(snap, "en")
assertContains(t, doc, "<!doctype html>")
assertContains(t, doc, "<title>Investigate &lt;flaky&gt; test — kandev share</title>")
@@ -64,7 +66,7 @@ func TestBuildShareHTML_GroupsConsecutiveAssistantMessages(t *testing.T) {
{Role: roleUser, Blocks: []Block{{Kind: blockKindText, Text: "ok"}}},
},
}
doc := BuildShareHTML(snap)
doc := BuildShareHTML(snap, "en")
// 3 groups expected: user, assistant (merged 3 messages), user — so 3 sections.
if got := strings.Count(doc, `<section class="group `); got != 3 {
t.Fatalf("expected 3 groups, got %d in:\n%s", got, doc)
@@ -85,7 +87,7 @@ func TestBuildShareHTML_ToolCallIsCollapsedByDefault(t *testing.T) {
}},
},
}
doc := BuildShareHTML(snap)
doc := BuildShareHTML(snap, "en")
// `<details>` without `open` is closed by default.
if strings.Contains(doc, "<details class=\"tool tool-call\" open") {
t.Fatal("tool call must not be open by default")
@@ -106,7 +108,7 @@ func TestBuildShareHTML_DiffLineClasses(t *testing.T) {
}},
},
}
doc := BuildShareHTML(snap)
doc := BuildShareHTML(snap, "en")
for _, cls := range []string{"diff-add", "diff-del", "diff-hunk", "diff-file", "diff-ctx"} {
if !strings.Contains(doc, `class="`+cls+`"`) {
t.Fatalf("missing class %q in diff: %s", cls, doc)
@@ -124,7 +126,7 @@ func TestBuildShareHTML_InlineBackticksAndFencedCode(t *testing.T) {
}},
},
}
doc := BuildShareHTML(snap)
doc := BuildShareHTML(snap, "en")
assertContains(t, doc, `<code>t.Cleanup</code>`)
assertContains(t, doc, `<pre><code class="language-go">`)
assertContains(t, doc, "func TestX(t *testing.T)")
@@ -143,7 +145,7 @@ func TestBuildShareHTML_RendersMarkdownWithoutUnsafeHTML(t *testing.T) {
},
}
doc := BuildShareHTML(snap)
doc := BuildShareHTML(snap, "en")
assertContains(t, doc, "<h2>Summary</h2>")
assertContains(t, doc, "<strong>Pushed</strong>")
@@ -159,18 +161,107 @@ func TestBuildShareHTML_RendersMarkdownWithoutUnsafeHTML(t *testing.T) {
func TestBuildShareHTML_EmptyMessagesShowsPlaceholder(t *testing.T) {
t.Parallel()
doc := BuildShareHTML(&Snapshot{Task: TaskMeta{Title: "Empty"}})
doc := BuildShareHTML(&Snapshot{Task: TaskMeta{Title: "Empty"}}, "en")
assertContains(t, doc, `class="empty"`)
assertContains(t, doc, "No messages.")
}
func TestBuildShareHTML_NilSafe(t *testing.T) {
t.Parallel()
if got := BuildShareHTML(nil); got == "" {
if got := BuildShareHTML(nil, "en"); got == "" {
t.Fatal("nil snapshot should produce a placeholder document")
}
}
// htmlFullKeys lists the catalog keys BuildShareHTML renders for a snapshot
// with content. Interpolated messages are asserted separately because T
// cannot resolve their placeholders.
var htmlFullKeys = []string{
"share.brandTag",
"share.roleYou",
keyRoleAssistant,
keyRoleSystem,
keyToolOutput,
keyToolOutputTruncated,
"share.redacted",
"share.ctaTryKandev",
}
// htmlEmptyKeys lists the copy that only appears on the degenerate path.
var htmlEmptyKeys = []string{
keyUntitledTask,
keyNoMessages,
"share.brandTag",
"share.ctaTryKandev",
}
func TestBuildShareHTML_LocalizesCopy(t *testing.T) {
t.Parallel()
for _, tc := range localizationCases(htmlFullKeys, htmlEmptyKeys) {
tc := tc
for _, locale := range []string{"en", "pseudo"} {
locale := locale
t.Run(tc.name+"/"+locale, func(t *testing.T) {
t.Parallel()
doc := BuildShareHTML(tc.snap, locale)
// <html lang> and the messages must agree on the locale.
assertContains(t, doc, `<html lang="`+locale+`">`)
for _, key := range tc.keys {
assertContains(t, doc, i18n.T(locale, key))
}
title := tc.snap.Task.Title
if title == "" {
title = i18n.T(locale, keyUntitledTask)
}
assertContains(t, doc, i18n.Tf(locale, "share.pageTitle",
map[string]any{"title": title}))
assertContains(t, doc, i18n.Tf(locale, keyMessageCount,
map[string]any{"count": len(tc.snap.Messages)}))
})
}
t.Run(tc.name+"/pseudo_differs", func(t *testing.T) {
t.Parallel()
assertPseudoDiffers(t, BuildShareHTML(tc.snap, "pseudo"), tc.keys)
})
}
}
// TestBuildShareHTML_UnsupportedLocaleFallsBack pins that a locale we have no
// catalog for degrades to English in both <html lang> and the copy, rather
// than emitting a lang attribute the browser cannot use.
func TestBuildShareHTML_UnsupportedLocaleFallsBack(t *testing.T) {
t.Parallel()
doc := BuildShareHTML(localizationSnapshot(), "klingon")
assertContains(t, doc, `<html lang="en">`)
assertContains(t, doc, i18n.T("en", "share.ctaTryKandev"))
}
// TestMessageRoleAttrs_TranslatesOnlyTheLabel guards the trap in this
// function: it returns a CSS class and an avatar next to the label, and the
// unknown-role branch echoes the raw wire value from the message store. Only
// the label may ever change with the locale.
func TestMessageRoleAttrs_TranslatesOnlyTheLabel(t *testing.T) {
t.Parallel()
for _, role := range []string{roleUser, roleAssistant, roleSystem} {
enCls, enLabel, enIcon := messageRoleAttrs(role, "en")
psCls, psLabel, psIcon := messageRoleAttrs(role, "pseudo")
if enCls != psCls {
t.Fatalf("role %q: CSS class changed with locale (%q vs %q)", role, enCls, psCls)
}
if enIcon != psIcon {
t.Fatalf("role %q: avatar changed with locale (%q vs %q)", role, enIcon, psIcon)
}
if enLabel == psLabel {
t.Fatalf("role %q: label %q is identical in both locales — it is hardcoded", role, enLabel)
}
}
// An unrecognised role is wire data, not copy: it is echoed verbatim.
cls, label, _ := messageRoleAttrs("reviewer", "pseudo")
if cls != "other" || label != "reviewer" {
t.Fatalf("unknown role should pass through, got cls=%q label=%q", cls, label)
}
}
func TestGroupMessages_RunsFusedAcrossEmptyMessages(t *testing.T) {
t.Parallel()
groups := groupMessages([]Message{
@@ -5,6 +5,8 @@ import (
"fmt"
"strings"
"time"
"github.com/kandev/kandev/internal/i18n"
)
// BuildGistREADME renders the README that appears at the top of the gist
@@ -13,34 +15,39 @@ import (
// view" pointer plus a markdown fallback for anyone who lands on the gist
// directly. renderedURL is the gist.githack.com link to share.html; pass
// "" if it's not known yet (the README is regenerated post-upload with
// the real link).
func BuildGistREADME(snap *Snapshot, renderedURL string) string {
// the real link). locale is the share creator's, captured at create time —
// see BuildShareHTML.
func BuildGistREADME(snap *Snapshot, renderedURL, locale string) string {
if snap == nil {
return "# kandev share\n"
}
var b strings.Builder
writeHero(&b, snap)
writeRenderedCTA(&b, renderedURL)
writeMetaDetails(&b, snap)
writeRedactionNote(&b, snap)
writeHero(&b, snap, locale)
writeRenderedCTA(&b, renderedURL, locale)
writeMetaDetails(&b, snap, locale)
writeRedactionNote(&b, snap, locale)
b.WriteString("---\n\n")
writeConversation(&b, snap.Messages)
writeFooter(&b, snap)
writeConversation(&b, snap.Messages, locale)
writeFooter(&b, snap, locale)
return b.String()
}
// writeRenderedCTA injects a prominent link to the styled HTML view so
// visitors who land on the raw gist know where to go.
func writeRenderedCTA(b *strings.Builder, renderedURL string) {
// visitors who land on the raw gist know where to go. The filename and the
// URL are interpolated rather than written into the catalog so neither is
// transliterated by the pseudo-locale — which would make them unusable.
func writeRenderedCTA(b *strings.Builder, renderedURL, locale string) {
if renderedURL == "" {
b.WriteString("> 📖 **Open `share.html`** for the rendered conversation.\n\n")
fmt.Fprintf(b, "> 📖 %s\n\n",
i18n.Tf(locale, "share.openShareHTML", map[string]any{"file": "`share.html`"}))
return
}
fmt.Fprintf(b, "> ✨ **[Open the rendered view →](%s)** for a properly styled conversation.\n\n", renderedURL)
fmt.Fprintf(b, "> ✨ %s\n\n",
i18n.Tf(locale, "share.openRenderedView", map[string]any{"url": renderedURL}))
}
func writeHero(b *strings.Builder, snap *Snapshot) {
fmt.Fprintf(b, "# %s\n\n", nonEmpty(snap.Task.Title, "Untitled task"))
func writeHero(b *strings.Builder, snap *Snapshot, locale string) {
fmt.Fprintf(b, "# %s\n\n", nonEmpty(snap.Task.Title, i18n.T(locale, keyUntitledTask)))
// Compact badge line: short, dense, scannable.
badges := []string{}
if v := snap.Session.AgentType; v != "" {
@@ -52,23 +59,24 @@ func writeHero(b *strings.Builder, snap *Snapshot) {
if v := snap.Session.ExecutorType; v != "" {
badges = append(badges, fmt.Sprintf("<kbd>%s</kbd>", v))
}
badges = append(badges, fmt.Sprintf("<kbd>%d messages</kbd>", len(snap.Messages)))
badges = append(badges, fmt.Sprintf("<kbd>%s</kbd>",
i18n.Tf(locale, keyMessageCount, map[string]any{"count": len(snap.Messages)})))
fmt.Fprintf(b, "<sub>%s</sub>\n\n", strings.Join(badges, " · "))
// Pitch line — also doubles as marketing.
b.WriteString("> 🚀 Shared from **[kandev](https://github.com/kdlbs/kandev)** — the open-source agentic dev environment.\n\n")
fmt.Fprintf(b, "> 🚀 %s\n\n", i18n.Tf(locale, "share.pitch", map[string]any{"url": kandevRepoURL}))
}
func writeMetaDetails(b *strings.Builder, snap *Snapshot) {
func writeMetaDetails(b *strings.Builder, snap *Snapshot, locale string) {
rows := []struct{ k, v string }{
{"Agent", snap.Session.AgentType},
{"Model", snap.Session.Model},
{"Executor", snap.Session.ExecutorType},
{"Started", formatTime(snap.Session.StartedAt)},
{"Completed", formatPtrTime(snap.Session.CompletedAt)},
{"Workflow step", snap.Task.WorkflowStep},
{i18n.T(locale, "share.metaAgent"), snap.Session.AgentType},
{i18n.T(locale, "share.metaModel"), snap.Session.Model},
{i18n.T(locale, "share.metaExecutor"), snap.Session.ExecutorType},
{i18n.T(locale, "share.metaStarted"), formatTime(snap.Session.StartedAt)},
{i18n.T(locale, "share.metaCompleted"), formatPtrTime(snap.Session.CompletedAt)},
{i18n.T(locale, "share.metaWorkflowStep"), snap.Task.WorkflowStep},
}
written := 0
b.WriteString("<details>\n<summary>📊 Session details</summary>\n\n")
fmt.Fprintf(b, "<details>\n<summary>📊 %s</summary>\n\n", i18n.T(locale, "share.sessionDetails"))
b.WriteString("| | |\n|---|---|\n")
for _, r := range rows {
if r.v == "" {
@@ -78,12 +86,12 @@ func writeMetaDetails(b *strings.Builder, snap *Snapshot) {
written++
}
if written == 0 {
b.WriteString("| _no metadata_ | |\n")
fmt.Fprintf(b, "| _%s_ | |\n", i18n.T(locale, "share.noMetadata"))
}
b.WriteString("\n</details>\n\n")
}
func writeRedactionNote(b *strings.Builder, snap *Snapshot) {
func writeRedactionNote(b *strings.Builder, snap *Snapshot, locale string) {
if len(snap.Redaction.AppliedRules) == 0 {
return
}
@@ -91,58 +99,64 @@ func writeRedactionNote(b *strings.Builder, snap *Snapshot) {
for i, r := range snap.Redaction.AppliedRules {
parts[i] = "`" + r + "`"
}
fmt.Fprintf(b, "> 🛡️ **Redacted before publish:** %s\n\n", strings.Join(parts, ", "))
fmt.Fprintf(b, "> 🛡️ **%s** %s\n\n",
i18n.T(locale, "share.redactedBeforePublish"), strings.Join(parts, ", "))
}
func writeConversation(b *strings.Builder, messages []Message) {
func writeConversation(b *strings.Builder, messages []Message, locale string) {
if len(messages) == 0 {
b.WriteString("_(No messages.)_\n\n")
fmt.Fprintf(b, "_(%s)_\n\n", i18n.T(locale, keyNoMessages))
return
}
for i, msg := range messages {
if i > 0 {
b.WriteString("\n")
}
writeMessage(b, msg)
writeMessage(b, msg, locale)
}
}
func writeMessage(b *strings.Builder, msg Message) {
fmt.Fprintf(b, "### %s\n\n", messageHeading(msg.Role))
func writeMessage(b *strings.Builder, msg Message, locale string) {
fmt.Fprintf(b, "### %s\n\n", messageHeading(msg.Role, locale))
wroteAny := false
for _, block := range msg.Blocks {
if writeBlock(b, block, msg.Role) {
if writeBlock(b, block, msg.Role, locale) {
wroteAny = true
}
}
if !wroteAny {
b.WriteString("_(empty)_\n\n")
fmt.Fprintf(b, "_(%s)_\n\n", i18n.T(locale, "share.emptyMessage"))
}
}
func messageHeading(role string) string {
// messageHeading pairs an avatar emoji with the translated role label. The
// emoji is not copy, and the default branch echoes the raw wire role — escaped,
// because it is written into a heading GitHub renders as HTML. roleForAuthor is
// total over the three constants today, so that branch is unreachable from a
// built snapshot; the escape is there so it stays safe if that ever changes.
func messageHeading(role, locale string) string {
switch role {
case roleUser:
return "🧑 User"
return "🧑 " + i18n.T(locale, "share.roleUser")
case roleAssistant:
return "🤖 Assistant"
return "🤖 " + i18n.T(locale, keyRoleAssistant)
case roleSystem:
return "⚙️ System"
return "⚙️ " + i18n.T(locale, keyRoleSystem)
}
return role
return escapeHTML(role)
}
// writeBlock writes a single block. Returns true if it produced any output —
// callers use this to detect "all blocks were empty" so they can show a
// placeholder instead of a bare heading.
func writeBlock(b *strings.Builder, block Block, role string) bool {
func writeBlock(b *strings.Builder, block Block, role, locale string) bool {
switch block.Kind {
case blockKindText:
return writeText(b, block.Text, role)
case blockKindToolCall:
return writeToolCall(b, block)
case blockKindToolResult:
return writeToolResult(b, block)
return writeToolResult(b, block, locale)
case blockKindDiff:
return writeDiff(b, block)
}
@@ -189,18 +203,18 @@ func writeToolCall(b *strings.Builder, block Block) bool {
return true
}
func writeToolResult(b *strings.Builder, block Block) bool {
func writeToolResult(b *strings.Builder, block Block, locale string) bool {
if strings.TrimSpace(block.Output) == "" {
return false
}
label := "📤 Tool output"
if block.Truncated {
label += " <em>(truncated)</em>"
}
// HTML <pre> avoids the triple-backtick collision when the tool output
// itself contains a code fence.
fmt.Fprintf(b, "<details>\n<summary>%s</summary>\n\n<pre><code>%s</code></pre>\n\n</details>\n\n",
label, escapeHTML(strings.TrimRight(block.Output, "\n")))
// The label lands inside a <summary> tag, so it is escaped like every other
// value in an HTML context here — today's catalog has nothing to escape, but
// a translator adding "&" or an <em> would otherwise inject raw markup.
fmt.Fprintf(b, "<details>\n<summary>📤 %s</summary>\n\n<pre><code>%s</code></pre>\n\n</details>\n\n",
escapeHTML(i18n.T(locale, toolOutputKey(block.Truncated))),
escapeHTML(strings.TrimRight(block.Output, "\n")))
return true
}
@@ -213,10 +227,12 @@ func writeDiff(b *strings.Builder, block Block) bool {
return true
}
func writeFooter(b *strings.Builder, snap *Snapshot) {
func writeFooter(b *strings.Builder, snap *Snapshot, locale string) {
b.WriteString("\n---\n\n")
b.WriteString("<sub>📦 Raw export: [`snapshot.json`](#file-snapshot-json) · ")
b.WriteString("Built with [kandev](https://github.com/kdlbs/kandev)")
// Only the two labels are copy; the filename, the anchor, the brand name
// and the URL are not.
fmt.Fprintf(b, "<sub>📦 %s [`snapshot.json`](#file-snapshot-json) · ", i18n.T(locale, "share.rawExport"))
fmt.Fprintf(b, "%s [kandev](%s)", i18n.T(locale, "share.builtWith"), kandevRepoURL)
if snap.KandevVersion != "" {
fmt.Fprintf(b, " %s", snap.KandevVersion)
}
@@ -5,6 +5,8 @@ import (
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/i18n"
)
func TestBuildGistREADME_FullConversation(t *testing.T) {
@@ -45,7 +47,7 @@ func TestBuildGistREADME_FullConversation(t *testing.T) {
Redaction: RedactionLog{AppliedRules: []string{RuleAbsPath}},
}
md := BuildGistREADME(snap, "https://gist.githack.com/jane/mock-gist-1/raw/share.html")
md := BuildGistREADME(snap, "https://gist.githack.com/jane/mock-gist-1/raw/share.html", "en")
assertContains(t, md, "# Investigate flaky test")
assertContains(t, md, "<kbd>claude-acp</kbd>")
assertContains(t, md, "<kbd>claude-opus-4-7</kbd>")
@@ -72,16 +74,183 @@ func TestBuildGistREADME_FullConversation(t *testing.T) {
func TestBuildGistREADME_NilAndEmpty(t *testing.T) {
t.Parallel()
if got := BuildGistREADME(nil, ""); got == "" {
if got := BuildGistREADME(nil, "", "en"); got == "" {
t.Fatal("nil snapshot should still produce a non-empty README")
}
empty := &Snapshot{Task: TaskMeta{Title: "Untitled"}}
got := BuildGistREADME(empty, "https://gist.githack.com/jane/g1/raw/share.html")
got := BuildGistREADME(empty, "https://gist.githack.com/jane/g1/raw/share.html", "en")
if !strings.Contains(got, "_(No messages.)_") {
t.Fatalf("expected empty-messages placeholder, got: %s", got)
}
}
// markdownFullKeys lists the catalog keys BuildGistREADME renders for a
// snapshot with content. Interpolated messages are asserted separately
// because T cannot resolve their placeholders.
var markdownFullKeys = []string{
"share.roleUser",
keyRoleAssistant,
keyRoleSystem,
keyToolOutput,
keyToolOutputTruncated,
"share.emptyMessage",
"share.redactedBeforePublish",
"share.sessionDetails",
"share.metaAgent",
"share.metaModel",
"share.metaExecutor",
"share.metaStarted",
"share.metaCompleted",
"share.metaWorkflowStep",
"share.rawExport",
"share.builtWith",
}
// markdownEmptyKeys lists the copy that only appears on the degenerate path.
var markdownEmptyKeys = []string{
keyUntitledTask,
keyNoMessages,
"share.noMetadata",
"share.sessionDetails",
"share.rawExport",
"share.builtWith",
}
func TestBuildGistREADME_LocalizesCopy(t *testing.T) {
t.Parallel()
for _, tc := range localizationCases(markdownFullKeys, markdownEmptyKeys) {
tc := tc
for _, locale := range []string{"en", "pseudo"} {
locale := locale
t.Run(tc.name+"/"+locale, func(t *testing.T) {
t.Parallel()
md := BuildGistREADME(tc.snap, "", locale)
for _, key := range tc.keys {
assertContains(t, md, i18n.T(locale, key))
}
assertContains(t, md, i18n.Tf(locale, keyMessageCount,
map[string]any{"count": len(tc.snap.Messages)}))
assertContains(t, md, i18n.Tf(locale, "share.pitch",
map[string]any{"url": kandevRepoURL}))
})
}
t.Run(tc.name+"/pseudo_differs", func(t *testing.T) {
t.Parallel()
assertPseudoDiffers(t, BuildGistREADME(tc.snap, "", "pseudo"), tc.keys)
})
}
}
// TestBuildGistREADME_LocalizesBothCTAs covers the two mutually exclusive
// call-to-action branches, and pins that the interpolated filename and URL
// survive the pseudo locale untransliterated — a transliterated `share.html`
// or gist URL would be a dead pointer.
func TestBuildGistREADME_LocalizesBothCTAs(t *testing.T) {
t.Parallel()
const rendered = "https://gist.githack.com/jane/g1/raw/share.html"
for _, locale := range []string{"en", "pseudo"} {
locale := locale
t.Run(locale, func(t *testing.T) {
t.Parallel()
withoutURL := BuildGistREADME(localizationSnapshot(), "", locale)
assertContains(t, withoutURL, i18n.Tf(locale, "share.openShareHTML",
map[string]any{"file": "`share.html`"}))
assertContains(t, withoutURL, "`share.html`")
withURL := BuildGistREADME(localizationSnapshot(), rendered, locale)
assertContains(t, withURL, i18n.Tf(locale, "share.openRenderedView",
map[string]any{"url": rendered}))
assertContains(t, withURL, rendered)
})
}
}
// TestMessageHeading_TranslatesOnlyTheLabel mirrors the HTML builder's
// TestMessageRoleAttrs_TranslatesOnlyTheLabel: the avatar is decoration, the
// label is copy, and an unrecognised role is wire data echoed through.
func TestMessageHeading_TranslatesOnlyTheLabel(t *testing.T) {
t.Parallel()
for _, role := range []string{roleUser, roleAssistant, roleSystem} {
en, pseudo := messageHeading(role, "en"), messageHeading(role, "pseudo")
if en == pseudo {
t.Fatalf("role %q: heading %q is identical in both locales — it is hardcoded", role, en)
}
// The avatar is the first rune and must not change with the locale.
if []rune(en)[0] != []rune(pseudo)[0] {
t.Fatalf("role %q: avatar changed with locale (%q vs %q)", role, en, pseudo)
}
}
// An unrecognised role is wire data: passed through, and escaped because the
// heading is rendered as HTML by GitHub.
if got := messageHeading("reviewer", "pseudo"); got != "reviewer" {
t.Fatalf("unknown role should pass through verbatim, got %q", got)
}
if got := messageHeading("<img src=x>", "en"); got != "&lt;img src=x&gt;" {
t.Fatalf("unknown role must be HTML-escaped, got %q", got)
}
}
// localizationCase pairs a fixture with the catalog keys its render must
// contain. Two are needed per builder because some copy only appears on the
// degenerate path — an untitled task with no messages and no metadata.
type localizationCase struct {
name string
snap *Snapshot
keys []string
}
func localizationCases(fullKeys, emptyKeys []string) []localizationCase {
return []localizationCase{
{name: "full", snap: localizationSnapshot(), keys: fullKeys},
{name: "degenerate", snap: &Snapshot{}, keys: emptyKeys},
}
}
// localizationSnapshot exercises every branch of both builders that renders
// copy: all three roles, a plain and a truncated tool result, a message whose
// blocks are all blank, a redaction note, and complete session metadata.
// Every value is deliberately free of English words so the pseudo-locale
// assertions cannot pass by coincidence.
func localizationSnapshot() *Snapshot {
completed := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC)
return &Snapshot{
Task: TaskMeta{Title: "Fixture", WorkflowStep: "step-1"},
Session: SessionMeta{
AgentType: "claude-acp",
Model: "claude-opus-4-7",
ExecutorType: "local_docker",
StartedAt: completed.Add(-time.Minute),
CompletedAt: &completed,
},
Messages: []Message{
{Role: roleUser, Blocks: []Block{{Kind: blockKindText, Text: "q"}}},
{Role: roleAssistant, Blocks: []Block{{Kind: blockKindToolResult, Output: "out"}}},
{Role: roleAssistant, Blocks: []Block{
{Kind: blockKindToolResult, Output: "cut", Truncated: true},
}},
{Role: roleSystem, Blocks: []Block{{Kind: blockKindText, Text: "s"}}},
// Blocks present but all blank — drives the "(empty)" placeholder.
{Role: roleUser, Blocks: []Block{{Kind: blockKindText, Text: " "}}},
},
Redaction: RedactionLog{AppliedRules: []string{RuleAbsPath}},
}
}
// assertPseudoDiffers is the real proof that a string is externalized: a
// hardcoded literal renders byte-identically in every locale, so finding the
// English message inside a pseudo-locale render means the key is decorative
// and the call site never went through the catalog.
func assertPseudoDiffers(t *testing.T, pseudoDoc string, keys []string) {
t.Helper()
for _, key := range keys {
english := i18n.T("en", key)
if strings.Contains(pseudoDoc, english) {
t.Fatalf("key %q still renders its English message %q under the pseudo locale — the string is hardcoded",
key, english)
}
}
}
func assertContains(t *testing.T, haystack, needle string) {
t.Helper()
if !strings.Contains(haystack, needle) {
+5 -1
View File
@@ -9,6 +9,7 @@ import (
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/i18n"
)
// HTTPHandlers wires the share endpoints. RegisterRoutes is the public entry
@@ -136,7 +137,10 @@ func (h *HTTPHandlers) httpCreate(c *gin.Context) {
return
}
share, err := h.svc.CreateShare(ctx, sessionID)
// The gist is a static file: nobody makes a request to us when they open
// it, so the creator's locale is the only one we will ever know. Resolve
// it here and thread it down rather than letting the builders guess.
share, err := h.svc.CreateShare(ctx, sessionID, i18n.FromRequest(c.Request))
if mapped, status := mapShareError(err); mapped != nil {
h.logServerError(err, "create share failed", sessionID)
c.JSON(status, mapped)
+47 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/i18n"
"github.com/kandev/kandev/internal/task/models"
)
@@ -137,7 +138,7 @@ func TestHTTP_List_ReturnsSharesNewestFirst(t *testing.T) {
handlers, svc := newHandlersForTest(t, reader, backend, true)
router := newGinRouter(handlers)
if _, err := svc.CreateShare(context.Background(), "s-1"); err != nil {
if _, err := svc.CreateShare(context.Background(), "s-1", "en"); err != nil {
t.Fatalf("create: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/t-1/sessions/s-1/shares", nil)
@@ -163,7 +164,7 @@ func TestHTTP_Revoke_Success(t *testing.T) {
handlers, svc := newHandlersForTest(t, reader, backend, true)
router := newGinRouter(handlers)
share, err := svc.CreateShare(context.Background(), "s-1")
share, err := svc.CreateShare(context.Background(), "s-1", "en")
if err != nil {
t.Fatalf("create: %v", err)
}
@@ -297,3 +298,47 @@ func TestHTTP_Revoke_NotFound(t *testing.T) {
t.Fatalf("want 404, got %d", rec.Code)
}
}
// TestHTTP_Create_ResolvesCreatorLocale pins the whole chain end to end: the
// creator's locale is read off the request and reaches the backend, because
// that is the only moment a static gist can be given a language. Table covers
// the two ways a locale arrives and the two ways it degrades to English.
func TestHTTP_Create_ResolvesCreatorLocale(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
cookie string
acceptLanguage string
want string
}{
{name: "cookie wins over accept-language", cookie: "pseudo", acceptLanguage: "en", want: "pseudo"},
{name: "accept-language when no cookie", acceptLanguage: "pseudo;q=1.0, en;q=0.8", want: "pseudo"},
{name: "no signal falls back to en", want: "en"},
{name: "unsupported cookie falls back to en", cookie: "klingon", want: "en"},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
backend := &mockBackend{nextID: "gist-loc"}
handlers, _ := newHandlersForTest(t, completedSession(), backend, true)
router := newGinRouter(handlers)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/t-1/sessions/s-1/shares", nil)
if tc.cookie != "" {
req.AddCookie(&http.Cookie{Name: i18n.LocaleCookie, Value: tc.cookie})
}
if tc.acceptLanguage != "" {
req.Header.Set("Accept-Language", tc.acceptLanguage)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
}
if len(backend.uploadLocales) != 1 || backend.uploadLocales[0] != tc.want {
t.Fatalf("backend received locales %v, want [%s]", backend.uploadLocales, tc.want)
}
})
}
}
+7 -2
View File
@@ -42,7 +42,12 @@ func (s *Service) PreviewSnapshot(ctx context.Context, taskSessionID string) (*S
// CreateShare builds a snapshot, uploads it via the configured backend, and
// records the row in the repository.
func (s *Service) CreateShare(ctx context.Context, taskSessionID string) (*Share, error) {
//
// locale comes from the creating request and fixes the language of the
// published artifact; the artifact is static, so this is the only point at
// which a locale can be chosen. Pass i18n.DefaultLocale when there is no
// request to resolve one from.
func (s *Service) CreateShare(ctx context.Context, taskSessionID, locale string) (*Share, error) {
workspaceID, err := s.workspaceForSession(ctx, taskSessionID)
if err != nil {
return nil, err
@@ -57,7 +62,7 @@ func (s *Service) CreateShare(ctx context.Context, taskSessionID string) (*Share
if err != nil {
return nil, fmt.Errorf("marshal snapshot: %w", err)
}
extID, extURL, err := s.backend.Upload(ctx, workspaceID, snap)
extID, extURL, err := s.backend.Upload(ctx, workspaceID, snap, locale)
if err != nil {
return nil, fmt.Errorf("upload snapshot: %w", err)
}
@@ -16,6 +16,7 @@ import (
type mockBackend struct {
uploads int
uploadWorkspaces []string
uploadLocales []string
deletes []string
deleteWorkspaces []string
accessWorkspaces []string
@@ -33,12 +34,15 @@ func (m *mockBackend) CheckAccess(_ context.Context, workspaceID string) error {
return m.accessErr
}
func (m *mockBackend) Upload(_ context.Context, workspaceID string, _ *Snapshot) (string, string, error) {
func (m *mockBackend) Upload(
_ context.Context, workspaceID string, _ *Snapshot, locale string,
) (string, string, error) {
if m.uploadErr != nil {
return "", "", m.uploadErr
}
m.uploads++
m.uploadWorkspaces = append(m.uploadWorkspaces, workspaceID)
m.uploadLocales = append(m.uploadLocales, locale)
id := m.nextID
if id == "" {
id = "gist-1"
@@ -79,7 +83,7 @@ func TestService_CreateShare_HappyPath(t *testing.T) {
mock := &mockBackend{nextID: "abc", nextURL: "https://gist.github.com/u/abc"}
svc := New(repo, completedSession(), mock, nil, "test-version")
share, err := svc.CreateShare(context.Background(), "s-1")
share, err := svc.CreateShare(context.Background(), "s-1", "en")
if err != nil {
t.Fatalf("create: %v", err)
}
@@ -109,7 +113,7 @@ func TestService_CreateShare_RejectsPreHistorySession(t *testing.T) {
mock := &mockBackend{}
svc := New(repo, reader, mock, nil, "v")
_, err := svc.CreateShare(context.Background(), "s-1")
_, err := svc.CreateShare(context.Background(), "s-1", "en")
if !errors.Is(err, ErrSessionNotShareable) {
t.Fatalf("expected ErrSessionNotShareable, got %v", err)
}
@@ -126,7 +130,7 @@ func TestService_CreateShare_AllowsRunningSession(t *testing.T) {
mock := &mockBackend{nextID: "g1"}
svc := New(repo, reader, mock, nil, "v")
share, err := svc.CreateShare(context.Background(), "s-1")
share, err := svc.CreateShare(context.Background(), "s-1", "en")
if err != nil {
t.Fatalf("running session should be shareable, got %v", err)
}
@@ -141,7 +145,7 @@ func TestService_CreateShare_BackendErrorLeavesNoRow(t *testing.T) {
mock := &mockBackend{uploadErr: errors.New("gateway down")}
svc := New(repo, completedSession(), mock, nil, "v")
_, err := svc.CreateShare(context.Background(), "s-1")
_, err := svc.CreateShare(context.Background(), "s-1", "en")
if err == nil {
t.Fatal("expected upload error")
}
@@ -163,7 +167,7 @@ func TestService_RevokeShare_DeletesAndMarksRevoked(t *testing.T) {
mock := &mockBackend{nextID: "abc"}
svc := New(repo, completedSession(), mock, nil, "v")
share, err := svc.CreateShare(context.Background(), "s-1")
share, err := svc.CreateShare(context.Background(), "s-1", "en")
if err != nil {
t.Fatalf("create: %v", err)
}
@@ -192,7 +196,7 @@ func TestService_CreateShare_FailsClosedWithoutTaskWorkspace(t *testing.T) {
reader.task.WorkspaceID = ""
mock := &mockBackend{}
svc := New(repo, reader, mock, nil, "v")
_, err := svc.CreateShare(context.Background(), "s-1")
_, err := svc.CreateShare(context.Background(), "s-1", "en")
if !errors.Is(err, ErrWorkspaceRequired) || mock.uploads != 0 {
t.Fatalf("CreateShare() error = %v, uploads = %d", err, mock.uploads)
}
@@ -204,7 +208,7 @@ func TestService_RevokeShare_IsIdempotentOnSecondCall(t *testing.T) {
mock := &mockBackend{nextID: "abc"}
svc := New(repo, completedSession(), mock, nil, "v")
share, err := svc.CreateShare(context.Background(), "s-1")
share, err := svc.CreateShare(context.Background(), "s-1", "en")
if err != nil {
t.Fatalf("create: %v", err)
}
@@ -227,7 +231,7 @@ func TestService_RevokeShare_SurvivesUpstream404(t *testing.T) {
mock := &mockBackend{nextID: "abc"}
svc := New(repo, completedSession(), mock, nil, "v")
share, err := svc.CreateShare(context.Background(), "s-1")
share, err := svc.CreateShare(context.Background(), "s-1", "en")
if err != nil {
t.Fatalf("create: %v", err)
}
@@ -251,7 +255,7 @@ func TestService_RevokeShare_PropagatesOtherBackendErrors(t *testing.T) {
mock := &mockBackend{nextID: "abc"}
svc := New(repo, completedSession(), mock, nil, "v")
share, err := svc.CreateShare(context.Background(), "s-1")
share, err := svc.CreateShare(context.Background(), "s-1", "en")
if err != nil {
t.Fatalf("create: %v", err)
}
@@ -287,3 +291,25 @@ func TestService_PreviewSnapshot_ReturnsRedactedWithoutUpload(t *testing.T) {
t.Fatalf("preview must not call backend Upload")
}
}
// TestService_CreateShare_ForwardsLocaleToBackend pins the middle hop of the
// locale chain. The published artifact is static, so this argument is the only
// thing that decides its language — a hop that dropped it would silently
// publish English and no render test would notice.
func TestService_CreateShare_ForwardsLocaleToBackend(t *testing.T) {
t.Parallel()
for _, locale := range []string{"en", "pseudo"} {
locale := locale
t.Run(locale, func(t *testing.T) {
t.Parallel()
mock := &mockBackend{}
svc := New(newTestRepo(t), completedSession(), mock, nil, "v")
if _, err := svc.CreateShare(context.Background(), "s-1", locale); err != nil {
t.Fatalf("CreateShare: %v", err)
}
if len(mock.uploadLocales) != 1 || mock.uploadLocales[0] != locale {
t.Fatalf("backend received locales %v, want [%s]", mock.uploadLocales, locale)
}
})
}
}
@@ -0,0 +1,101 @@
# ADR-2026-08-01-share-artifact-locale: Shared-Task Artifacts Render in the Creator's Locale
**Status:** accepted
**Date:** 2026-08-01
**Area:** backend
## Context
Public task sharing publishes three files to a GitHub Gist: `share.html` (the styled
conversation, the link users actually send), `README.md`, and `snapshot.json`. The
first two are rendered by Go and, until now, held hardcoded English copy — "Untitled
task", "You", "Assistant", "Tool output", the metadata table labels, and so on.
Localizing them raises a question the rest of the product never has to answer. Every
other localized surface resolves its locale per request: the SPA reads the
`kandev_locale` cookie, and `i18n.FromRequest` does the same for the SPA-unavailable
error pages. A gist is a **static file on GitHub**. When a reader opens the link,
they make no request to Kandev — there is no cookie, no `Accept-Language`, no
request at all. The locale cannot be resolved at read time, so it must be chosen at
write time, and whatever is chosen is frozen into the published bytes.
## Decision
Shared-task artifacts render in the **share creator's locale**, resolved with
`i18n.FromRequest` in the create handler and threaded explicitly down the call
chain:
```text
httpCreate → Service.CreateShare(ctx, sessionID, locale)
→ Backend.Upload(ctx, workspaceID, snap, locale)
→ BuildShareHTML(snap, locale) / BuildGistREADME(snap, url, locale) / gistDescription(snap, locale)
```
The locale is an ordinary argument at every hop. It is deliberately not a
`context.Context` value and not package state: the builders are pure functions of
(snapshot, locale), which is what makes the per-locale render tests possible.
`i18n.Normalize` is applied where the value is emitted as `<html lang>`, so the
document's declared language and its message lookups can never disagree.
To support this the `i18n` package gained `Tf(locale, key, vars)` — `{{name}}`
interpolation plus i18next-compatible plural selection (`key_one` / `key_other`
chosen from a `count` var). Call sites never `fmt.Sprintf` a translated string and
never build a plural by appending "s"; both would put word order and grammar in Go
instead of in the catalog.
The locale is **not persisted** on the `task_shares` row. Nothing re-renders an
artifact after upload today, so there is nothing to be consistent with.
## Consequences
- A share created by a user working in French produces a French artifact. The
language matches the author's, which is the only signal we have and is consistent
with every other surface in the product.
- The published language is fixed at creation. A reader who prefers another language
gets the creator's; re-sharing the session under a different locale is the only way
to change it. This is inherent to publishing a static file and is accepted.
- `Backend.Upload` gained a parameter. Any future share backend must accept and honor
a locale rather than assuming English.
- The pseudo-locale is now a genuine completeness oracle for these files: rendering a
snapshot under `pseudo` and finding an English message proves a literal was left
hardcoded, which is exactly what the new builder tests assert.
- **If a re-render path is ever added** (for example regenerating `README.md`
post-upload with the real rendered URL, which the code comments contemplate), the
creator's locale must be persisted on the share row first — otherwise the
regenerated file silently reverts to whatever locale that later caller happens to
hold.
## Alternatives Considered
1. **Always English.** Rejected. It is defensible — a public link goes to arbitrary
readers — but it makes the artifact the one place in the product that ignores the
user's language choice, and it would leave the copy permanently un-exercised by
the pseudo-locale, so regressions would be invisible. English remains the
fallback for any unsupported locale anyway, so the failure mode is already
covered.
2. **Creator's locale with a `?locale=` override on the create request.** Rejected
for now as unjustified surface: it adds a public API parameter and its validation
and tests to serve a case nobody has asked for. The threading this ADR
establishes is exactly what such an override would need, so adding it later is
cheap.
3. **Carry the locale in `context.Context`.** Rejected. It would make the builders
depend on ambient state, and a caller that forgot to populate the context would
silently emit English with no compile-time signal.
## Notes
The rule for what counts as copy is unchanged and worth restating, because this code
mixes the two freely:
- `messageRoleAttrs` returns a **CSS class**, a **label**, and an **emoji**. Only the
label is copy. Its fallback branch echoes the raw role from the message store —
wire data, never translated.
- `roleUser` / `roleAssistant` / `roleSystem` are wire values, not display strings.
- In "Built with [kandev](…)" only "Built with" is copy; brand names, URLs, filenames
(`share.html`, `snapshot.json`) and emoji are not. Interpolated values keep
filenames and URLs out of the catalog so the pseudo-locale cannot transliterate
them into dead pointers.
- The ~37 `http.Error` strings in the backend stay English: they are diagnostics the
SPA maps onto its own translated toasts, and translating them would duplicate the
copy in two places.
+1
View File
@@ -97,3 +97,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-01-repository-task-executor-defaults | [Resolve Task Executor Policy Before Last-Used Profile](2026-08-01-repository-task-executor-defaults.md) | accepted | frontend | 2026-08-01 |
| 2026-08-01-validate-mcp-tool-arguments | [Validate MCP Tool Arguments at the Shared Server Boundary](2026-08-01-validate-mcp-tool-arguments.md) | accepted | backend, protocol | 2026-08-01 |
| 2026-08-01-global-run-scheduler-ownership | [Separate Global Run Dispatch from Office Maintenance](2026-08-01-global-run-scheduler-ownership.md) | accepted | backend, workflow | 2026-08-01 |
| 2026-08-01-share-artifact-locale | [Shared-Task Artifacts Render in the Creator's Locale](2026-08-01-share-artifact-locale.md) | accepted | backend | 2026-08-01 |
+45 -10
View File
@@ -313,22 +313,47 @@ completeness check: anything still rendering plain English under it was missed.
## Backend
`apps/backend/internal/i18n` localizes the strings the **backend renders directly
to a browser**. Right now that is exactly the plain-text error pages served when
the SPA bundle cannot be delivered — three messages. Catalogs are embedded JSON
(`locales/<locale>.json`, flat `key: message`) mirroring the frontend's shape;
`pseudo` is generated from `en` by the frontend's `pnpm run i18n:pseudo`, which
writes both catalogs so the QA locale is a complete oracle.
to a browser**. That is two surfaces, and between them they are now complete: the
plain-text error pages served when the SPA bundle cannot be delivered
(`webapp.*`), and the shared-task artifacts — `share.html`, the gist README, and
the gist description — that Go writes as HTML/Markdown (`share.*`). Catalogs are
embedded JSON (`locales/<locale>.json`, flat `key: message`) mirroring the
frontend's shape; `pseudo` is generated from `en` by the frontend's
`pnpm run i18n:pseudo`, which writes both catalogs so the QA locale is a complete
oracle.
```go
locale := i18n.FromRequest(r) // cookie -> Accept-Language -> "en"
http.Error(w, i18n.T(locale, "webapp.shellUnavailable"), http.StatusServiceUnavailable)
```
The other server-rendered surface is the shared-task artifacts (`share.html`, the
gist README and description) that Go writes as HTML/Markdown. Those are **not yet
localized**. They will need placeholder interpolation and a way to carry the
locale to code too far from the HTTP layer to hold a `*http.Request` — add a `Tf`
and a context-carried locale alongside the existing helpers when that lands.
### Interpolation and plurals
`Tf` is the only supported way to build a message that carries a value. Never
`fmt.Sprintf` a translated string — that freezes the argument order in English,
and a translator cannot move a placeholder that is not in the catalog.
```go
i18n.Tf(locale, "share.pageTitle", map[string]any{"title": title}) // "{{title}} — kandev share"
i18n.Tf(locale, "share.messageCount", map[string]any{"count": n}) // share.messageCount_one / _other
```
Plurals use the same `_one` / `_other` suffixes as the frontend, selected from a
`count` var; a key with no plural forms falls back to the base message. Values
are substituted verbatim, so escaping is the call site's job. Interpolate
anything the pseudo-locale must not transliterate — filenames, URLs, brand names —
rather than writing it into the catalog, or the QA locale turns it into a dead
pointer.
### Locale for artifacts that outlive the request
A shared task is a static file on GitHub: when a reader opens the link they make
no request to us, so there is no cookie or `Accept-Language` to read. The locale
is resolved once from the **creating** request and threaded as an ordinary
argument down `httpCreate → CreateShare → Backend.Upload → the builders`. Not a
context value, not package state — the builders stay pure functions of
(snapshot, locale), which is what makes the per-locale render tests possible. See
[ADR 2026-08-01](decisions/2026-08-01-share-artifact-locale.md).
`i18n.Normalize` is the single source of truth for locale support, shared with
`webapp.NormalizeLocale`, so `<html lang>` and message lookup can never disagree.
@@ -343,6 +368,16 @@ translated toasts rather than rendering the server's text. Translating them woul
make logs and support harder while helping no user. Also untranslated: log lines,
agent/ACP output, and CLI output.
Inside the localized surfaces, three categories look like copy but are not, and
the share builders mix all of them into the same lines:
- **Wire values.** `roleUser` / `roleAssistant` / `roleSystem` come from the
message store. `messageRoleAttrs` returns a CSS class, a label, and an emoji
together — only the label is copy, and its fallback branch echoes the raw role.
- **Identifiers.** Brand names, URLs, filenames (`share.html`, `snapshot.json`),
and CSS class names. In "Built with [kandev](…)", only "Built with" is copy.
- **Emoji**, which stay in the Go format string rather than the catalog.
**Contract for new user-facing backend output:** prefer returning a stable error
*code* the frontend translates. Only use `i18n.T` when Go itself renders the bytes
a user reads.