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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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àĩĺàƀĺē."
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <flaky> 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 != "<img src=x>" {
|
||||
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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user