From 14d91f5e3d8b16278a72e74e0de245cd915e5ec1 Mon Sep 17 00:00:00 2001 From: Aulma <37032612+Aulma@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:02:44 +0200 Subject: [PATCH] feat: add configurable browser tab title prefix (#2459) --- apps/backend/internal/backendapp/helpers.go | 6 +- .../internal/backendapp/helpers_test.go | 27 +++++++ apps/backend/internal/backendapp/main.go | 1 + .../internal/backendapp/web_runtime_test.go | 16 ++++- apps/backend/internal/common/config/config.go | 6 ++ .../internal/common/config/config_test.go | 22 ++++++ apps/backend/internal/webapp/payload.go | 7 ++ apps/backend/internal/webapp/shell.go | 67 +++++++++++++++++- apps/backend/internal/webapp/shell_test.go | 70 +++++++++++++++++++ apps/web/lib/browser/document-title.test.ts | 38 ++++++++++ apps/web/lib/browser/document-title.ts | 27 +++++++ apps/web/src/boot-payload.test.ts | 16 +++++ apps/web/src/boot-payload.ts | 8 +++ apps/web/src/main.tsx | 5 ++ docs/configuration.md | 2 + docs/public/configuration.md | 2 + 16 files changed, 316 insertions(+), 4 deletions(-) create mode 100644 apps/web/lib/browser/document-title.test.ts create mode 100644 apps/web/lib/browser/document-title.ts diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index c218ba4358..e23f59703b 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -548,6 +548,7 @@ type routeParams struct { repoCloner *repoclone.Cloner version string webInternalURL string + webTitlePrefix string devMode bool httpPort int features config.FeaturesConfig @@ -801,7 +802,7 @@ func webAppHandlerOptions(p routeParams) []webapp.HandlerOption { // webRuntimeConfig builds the SPA's runtime block. `req` supplies the active // locale (from the kandev_locale cookie) so the shell can set and the // client can activate the right catalog before first paint. -func webRuntimeConfig(debug bool, req *http.Request) webapp.RuntimeConfig { +func webRuntimeConfig(debug bool, titlePrefix string, req *http.Request) webapp.RuntimeConfig { return webapp.RuntimeConfig{ APIPrefix: "/api/v1", WebSocketPath: "/ws", @@ -812,6 +813,7 @@ func webRuntimeConfig(debug bool, req *http.Request) webapp.RuntimeConfig { // this from its own build mode. NonProduction: profiles.DetectEnvironment() != profiles.EnvProd, Locale: i18n.FromRequest(req), + TitlePrefix: strings.TrimSpace(titlePrefix), } } @@ -823,7 +825,7 @@ func bootPayload(ctx context.Context, req *http.Request, p routeParams, route we } payload := webapp.NewBootPayload( route, - webRuntimeConfig(p.devMode, req), + webRuntimeConfig(p.devMode, p.webTitlePrefix, req), initialState, ) payload.RouteData = routeData diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index 9060fc8700..f7c7d84817 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -1342,6 +1342,33 @@ func TestBootPayloadIncludesDebugRuntimeWhenDevMode(t *testing.T) { } } +func TestBootPayloadCarriesConfiguredTitlePrefix(t *testing.T) { + t.Parallel() + + payload := bootPayload( + context.Background(), + nil, + routeParams{webTitlePrefix: "TEST"}, + webapp.ClassifyRoute("/"), + ) + if got := payload.Runtime.TitlePrefix; got != "TEST" { + t.Fatalf("runtime.titlePrefix = %q, want %q", got, "TEST") + } +} + +func TestBootPayloadOmitsUnsetTitlePrefix(t *testing.T) { + t.Parallel() + + payload := bootPayload(context.Background(), nil, routeParams{}, webapp.ClassifyRoute("/")) + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal payload: %v", err) + } + if strings.Contains(string(raw), "titlePrefix") { + t.Fatalf("expected titlePrefix to be omitted, got: %s", raw) + } +} + func TestBootPayloadRestoresQuickChatSessions(t *testing.T) { harness := newBootStateTestHarness(t) ctx := context.Background() diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index a4e2f4a680..8990c763f0 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -1844,6 +1844,7 @@ func buildHTTPServer( repoCloner: repoCloner, version: Version, webInternalURL: cfg.Server.WebInternalURL, + webTitlePrefix: cfg.Server.WebTitlePrefix, devMode: cfg.Debug.DevMode || cfg.Debug.PprofEnabled, httpPort: resolvedHTTPPort(cfg), features: cfg.Features, diff --git a/apps/backend/internal/backendapp/web_runtime_test.go b/apps/backend/internal/backendapp/web_runtime_test.go index 9fcbae033c..339a42d1dd 100644 --- a/apps/backend/internal/backendapp/web_runtime_test.go +++ b/apps/backend/internal/backendapp/web_runtime_test.go @@ -10,9 +10,23 @@ import ( func TestWebRuntimeConfigAdvertisesLSPAutoInstallPreferences(t *testing.T) { request := httptest.NewRequest("GET", "/settings/editors", nil) - got := webRuntimeConfig(false, request).LSPAutoInstallPreferenceLanguages + got := webRuntimeConfig(false, "", request).LSPAutoInstallPreferenceLanguages want := lspinstaller.AutoInstallPreferenceLanguages() if !slices.Equal(got, want) { t.Fatalf("LSP auto-install preferences = %v, want %v", got, want) } } + +func TestWebRuntimeConfigTrimsTitlePrefix(t *testing.T) { + request := httptest.NewRequest("GET", "/", nil) + if got := webRuntimeConfig(false, " TEST ", request).TitlePrefix; got != "TEST" { + t.Fatalf("title prefix = %q, want %q", got, "TEST") + } +} + +func TestWebRuntimeConfigOmitsBlankTitlePrefix(t *testing.T) { + request := httptest.NewRequest("GET", "/", nil) + if got := webRuntimeConfig(false, " ", request).TitlePrefix; got != "" { + t.Fatalf("title prefix = %q, want empty", got) + } +} diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index daba595dc0..a20fa3b3e7 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -133,6 +133,10 @@ type ServerConfig struct { ReadTimeout int `mapstructure:"readTimeout"` // in seconds WriteTimeout int `mapstructure:"writeTimeout"` // in seconds WebInternalURL string `mapstructure:"webInternalUrl"` + // WebTitlePrefix prefixes the browser tab title as " Kandev" (for + // example "TEST") so several Kandev instances are distinguishable in + // adjacent browser tabs. Empty keeps the plain "Kandev" title. + WebTitlePrefix string `mapstructure:"webTitlePrefix"` } // splitHosts splits a comma-separated host string, trimming whitespace and @@ -515,6 +519,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("server.readTimeout", 30) v.SetDefault("server.writeTimeout", 30) v.SetDefault("server.webInternalUrl", "") + v.SetDefault("server.webTitlePrefix", "") // HomeDir default — empty means resolve from KANDEV_HOME_DIR env or ~/.kandev v.SetDefault("homeDir", "") @@ -670,6 +675,7 @@ func LoadWithPath(configPath string) (*Config, error) { _ = v.BindEnv("agent.standalonePort", "AGENTCTL_PORT", "KANDEV_AGENT_STANDALONE_PORT") _ = v.BindEnv("agent.standaloneHost", "KANDEV_AGENT_STANDALONE_HOST") _ = v.BindEnv("server.webInternalUrl", "KANDEV_WEB_INTERNAL_URL") + _ = v.BindEnv("server.webTitlePrefix", "KANDEV_WEB_TITLE_PREFIX") _ = v.BindEnv("homeDir", "KANDEV_HOME_DIR") _ = v.BindEnv("logging.level", "KANDEV_LOG_LEVEL") _ = v.BindEnv("events.namespace", "KANDEV_EVENTS_NAMESPACE") diff --git a/apps/backend/internal/common/config/config_test.go b/apps/backend/internal/common/config/config_test.go index 6f2cd45998..87454f505a 100644 --- a/apps/backend/internal/common/config/config_test.go +++ b/apps/backend/internal/common/config/config_test.go @@ -32,6 +32,28 @@ func TestGitHubCredentialBrokerConfigEnvironmentBinding(t *testing.T) { } } +func TestWebTitlePrefixEnvironmentBinding(t *testing.T) { + t.Setenv("KANDEV_WEB_TITLE_PREFIX", "TEST") + cfg, err := LoadWithPath(t.TempDir()) + if err != nil { + t.Fatalf("LoadWithPath: %v", err) + } + if got := cfg.Server.WebTitlePrefix; got != "TEST" { + t.Fatalf("web title prefix = %q, want %q", got, "TEST") + } +} + +func TestWebTitlePrefixDefaultsEmpty(t *testing.T) { + t.Setenv("KANDEV_WEB_TITLE_PREFIX", "") + cfg, err := LoadWithPath(t.TempDir()) + if err != nil { + t.Fatalf("LoadWithPath: %v", err) + } + if got := cfg.Server.WebTitlePrefix; got != "" { + t.Fatalf("web title prefix = %q, want empty", got) + } +} + // minimalValidConfig returns a Config that passes validate() out of the box. // Tests modify a copy to exercise individual validation branches. func minimalValidConfig() *Config { diff --git a/apps/backend/internal/webapp/payload.go b/apps/backend/internal/webapp/payload.go index 33ed487a49..2c9b5eb8f4 100644 --- a/apps/backend/internal/webapp/payload.go +++ b/apps/backend/internal/webapp/payload.go @@ -46,6 +46,13 @@ type RuntimeConfig struct { // before first paint. Sourced from the kandev_locale cookie; defaults to // "en". Also drives the shell's so first paint matches. Locale string `json:"locale,omitempty"` + // TitlePrefix distinguishes Kandev instances in adjacent browser tabs. It + // carries the operator-configured prefix (KANDEV_WEB_TITLE_PREFIX) with + // surrounding whitespace trimmed, not the composed title: the shell + // rewrites server-side for first paint, and the SPA composes the + // same "<prefix> Kandev" for the + // /api/v1/app-state boot path, which never renders through the shell. + TitlePrefix string `json:"titlePrefix,omitempty"` } // BootError is a serializable non-fatal boot-data error for partial hydration. diff --git a/apps/backend/internal/webapp/shell.go b/apps/backend/internal/webapp/shell.go index d20e93c1cc..1ad9ed30aa 100644 --- a/apps/backend/internal/webapp/shell.go +++ b/apps/backend/internal/webapp/shell.go @@ -4,7 +4,9 @@ import ( "bytes" "encoding/json" "fmt" + "html" "io/fs" + "strings" "github.com/kandev/kandev/internal/i18n" ) @@ -17,6 +19,14 @@ var headCloseTag = []byte("</head>") var htmlOpenTag = []byte("<html") +var titleOpenTag = []byte("<title") + +var titleCloseTag = []byte("") + +// ProductTitle is the browser tab title when no title prefix is configured. +// It is a product name, not translatable copy. +const ProductTitle = "Kandev" + // DefaultLocale is the source locale shipped as the message catalog. const DefaultLocale = i18n.DefaultLocale @@ -45,7 +55,62 @@ func RenderShellHTML(indexHTML []byte, payload BootPayload) ([]byte, error) { return nil, err } localized := rewriteHTMLLang(indexHTML, NormalizeLocale(payload.Runtime.Locale)) - return injectBeforeHeadClose(localized, script), nil + titled := rewriteTitle(localized, payload.Runtime.TitlePrefix) + return injectBeforeHeadClose(titled, script), nil +} + +// ComposeTitle returns the browser tab title for prefix: a trimmed-empty prefix +// keeps the plain product name, otherwise " Kandev". The SPA repeats +// this rule in apps/web/lib/browser/document-title.ts for the +// /api/v1/app-state boot path, which never renders through this shell. +func ComposeTitle(prefix string) string { + prefix = strings.TrimSpace(prefix) + if prefix == "" { + return ProductTitle + } + return prefix + " " + ProductTitle +} + +// rewriteTitle replaces the content of the shell's first element so the +// tab title is correct from first paint, with no JS-driven flash. A blank +// prefix, or a shell with no <title> element, leaves the input unchanged. +func rewriteTitle(indexHTML []byte, prefix string) []byte { + if strings.TrimSpace(prefix) == "" { + return indexHTML + } + start, end, ok := titleContentBounds(indexHTML) + if !ok { + return indexHTML + } + title := html.EscapeString(ComposeTitle(prefix)) + + out := make([]byte, 0, bytesCapacity(len(indexHTML), len(title))) + out = append(out, indexHTML[:start]...) + out = append(out, title...) + out = append(out, indexHTML[end:]...) + return out +} + +// titleContentBounds locates the text between the first <title ...> and its +// closing , reporting false when either tag is missing. The byte scan +// assumes the shell's first "') + if tagEnd < 0 { + return 0, 0, false + } + start = tagStart + tagEnd + 1 + closeIdx := bytes.Index(indexHTML[start:], titleCloseTag) + if closeIdx < 0 { + return 0, 0, false + } + return start, start + closeIdx, true } // rewriteHTMLLang sets the lang="" attribute on the shell's opening tag diff --git a/apps/backend/internal/webapp/shell_test.go b/apps/backend/internal/webapp/shell_test.go index b63d9d265a..37b6cab62d 100644 --- a/apps/backend/internal/webapp/shell_test.go +++ b/apps/backend/internal/webapp/shell_test.go @@ -157,6 +157,76 @@ func TestRenderShellInsertsLangWhenAbsent(t *testing.T) { } } +func TestRenderShellRewritesTitleFromPrefix(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + prefix string + wantTitle string + }{ + {name: "prefix set", prefix: "TEST", wantTitle: "TEST Kandev"}, + {name: "prefix trimmed", prefix: " TEST ", wantTitle: "TEST Kandev"}, + {name: "empty prefix keeps shell title", prefix: "", wantTitle: "Kandev"}, + {name: "blank prefix keeps shell title", prefix: " ", wantTitle: "Kandev"}, + { + name: "html special characters are escaped", + prefix: ``, + wantTitle: "</title><script>alert(1)</script> Kandev", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assets := fstest.MapFS{ + "index.html": { + Data: []byte(`Kandev`), + }, + } + payload := NewBootPayload(ClassifyRoute("/"), RuntimeConfig{TitlePrefix: tc.prefix}, nil) + html, err := RenderShell(assets, "index.html", payload) + if err != nil { + t.Fatalf("RenderShell: %v", err) + } + if got := string(html); !strings.Contains(got, tc.wantTitle) { + t.Fatalf("expected %q in shell, got: %s", tc.wantTitle, got) + } + }) + } +} + +func TestRenderShellLeavesShellWithoutTitleUntouched(t *testing.T) { + t.Parallel() + + assets := fstest.MapFS{ + "index.html": {Data: []byte(``)}, + } + payload := NewBootPayload(ClassifyRoute("/"), RuntimeConfig{TitlePrefix: "TEST"}, nil) + html, err := RenderShell(assets, "index.html", payload) + if err != nil { + t.Fatalf("RenderShell: %v", err) + } + if strings.Contains(string(html), " { + it.each([ + ["TEST", "TEST Kandev"], + [" staging ", "staging Kandev"], + ["", "Kandev"], + [" ", "Kandev"], + [undefined, "Kandev"], + ])("composes %o as %s", (prefix, expected) => { + expect(composeTitle(prefix)).toBe(expected); + }); +}); + +describe("applyTitlePrefix", () => { + it("prefixes the document title", () => { + const doc = fakeDocument("Kandev"); + applyTitlePrefix("TEST", doc); + expect(doc.title).toBe("TEST Kandev"); + }); + + it("trims surrounding whitespace from the prefix", () => { + const doc = fakeDocument("Kandev"); + applyTitlePrefix(" TEST ", doc); + expect(doc.title).toBe("TEST Kandev"); + }); + + it.each([undefined, "", " "])("leaves the title alone for %o", (prefix) => { + const doc = fakeDocument("Kandev"); + applyTitlePrefix(prefix, doc); + expect(doc.title).toBe("Kandev"); + }); +}); diff --git a/apps/web/lib/browser/document-title.ts b/apps/web/lib/browser/document-title.ts new file mode 100644 index 0000000000..5df0cc3d10 --- /dev/null +++ b/apps/web/lib/browser/document-title.ts @@ -0,0 +1,27 @@ +/** + * Browser tab title composition. Mirrors the Go rule in + * `apps/backend/internal/webapp/shell.go` (`ComposeTitle`): the shell rewrites + * `` server-side so the prefixed title is correct from first paint, and + * this covers the `/api/v1/app-state` boot path, which never renders through + * the shell. + * + * "Kandev" is the product name, not translatable copy, so no `t()` is involved + * — consistent with the static `<title>` in `index.html`. + */ +const PRODUCT_TITLE = "Kandev"; + +/** Returns `"<prefix> Kandev"`, or the plain product name for a blank prefix. */ +export function composeTitle(prefix: string | undefined): string { + const trimmed = prefix?.trim(); + return trimmed ? `${trimmed} ${PRODUCT_TITLE}` : PRODUCT_TITLE; +} + +/** + * Applies the operator-configured tab title prefix. A blank prefix leaves the + * document title alone: the served shell already says "Kandev", and + * overwriting it would clobber a title some other surface has set. + */ +export function applyTitlePrefix(prefix: string | undefined, doc: Document): void { + if (!prefix?.trim()) return; + doc.title = composeTitle(prefix); +} diff --git a/apps/web/src/boot-payload.test.ts b/apps/web/src/boot-payload.test.ts index d6099e033f..95b2dd73aa 100644 --- a/apps/web/src/boot-payload.test.ts +++ b/apps/web/src/boot-payload.test.ts @@ -76,6 +76,22 @@ describe("readBootPayload", () => { expect(readBootPayload(win).runtime?.debug).toBe(true); expect(win.__KANDEV_DEBUG).toBe(true); }); + + it("reads the browser tab title prefix from the runtime block", () => { + const win = { + __KANDEV_BOOT_PAYLOAD__: { runtime: { titlePrefix: "TEST" } }, + } as unknown as Window; + + expect(readBootPayload(win).runtime?.titlePrefix).toBe("TEST"); + }); + + it("leaves the title prefix undefined when the runtime block omits it", () => { + const win = { + __KANDEV_BOOT_PAYLOAD__: { runtime: { apiPrefix: "/api/v1" } }, + } as unknown as Window; + + expect(readBootPayload(win).runtime?.titlePrefix).toBeUndefined(); + }); }); describe("readBootPayload plugins", () => { diff --git a/apps/web/src/boot-payload.ts b/apps/web/src/boot-payload.ts index 950d57cfd5..62b219791d 100644 --- a/apps/web/src/boot-payload.ts +++ b/apps/web/src/boot-payload.ts @@ -26,6 +26,13 @@ export type BootRuntime = { nonProduction?: boolean; /** Active UI locale from the kandev_locale cookie; drives first-paint i18n. */ locale?: string; + /** + * Operator-configured browser tab title prefix (KANDEV_WEB_TITLE_PREFIX), so + * several Kandev instances are distinguishable in adjacent tabs. The Go shell + * already rewrites `<title>`; this covers the /api/v1/app-state boot path, + * which never renders through the shell. + */ + titlePrefix?: string; }; export type BootRouteData = { @@ -146,6 +153,7 @@ function readRuntime(value: Record<string, unknown>): BootRuntime { debug: value.debug === true ? true : undefined, nonProduction: value.nonProduction === true ? true : undefined, locale: readString(value.locale), + titlePrefix: readString(value.titlePrefix), }; } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 4094636417..a9a00baae9 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -15,6 +15,7 @@ import { RootErrorBoundary, RouteErrorBoundary } from "./app-error-boundary"; import { SpaRoutes } from "./spa-routes"; import { installVitePreloadRecovery } from "./vite-preload-recovery"; import { markRenderingEngine } from "@/lib/browser/rendering-engine"; +import { applyTitlePrefix } from "@/lib/browser/document-title"; installVitePreloadRecovery(); markRenderingEngine(document.documentElement); @@ -70,6 +71,10 @@ if (!root) { } void loadBootPayload().then(async (payload) => { + // The Go shell already rewrote <title>; this is the /api/v1/app-state boot + // path, which never renders through it. + applyTitlePrefix(payload.runtime?.titlePrefix, document); + // Only `en` ships in the entry chunk, so a non-English boot has to fetch its // catalogs. Awaited HERE, in the promise the mount already waited on, rather // than after mounting: with `returnNull: false` a missing key renders as the diff --git a/docs/configuration.md b/docs/configuration.md index d5eb83ca98..b6b6c683ae 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,6 +26,7 @@ All env vars use the `KANDEV_` prefix. Nested keys map by replacing `.` with `_` |---|---| | `server.port` | `KANDEV_SERVER_PORT` | | `server.webInternalUrl` | `KANDEV_SERVER_WEBINTERNALURL` (or alias `KANDEV_WEB_INTERNAL_URL`) | +| `server.webTitlePrefix` | `KANDEV_SERVER_WEBTITLEPREFIX` (or alias `KANDEV_WEB_TITLE_PREFIX`) | | `database.dbName` | `KANDEV_DATABASE_DBNAME` | | `logging.maxSizeMb` | `KANDEV_LOGGING_MAXSIZEMB` | | `homeDir` | `KANDEV_HOME_DIR` (alias) | @@ -48,6 +49,7 @@ server: readTimeout: 30 # seconds writeTimeout: 30 # seconds webInternalUrl: "" # internal URL the backend uses to call the web app + webTitlePrefix: "" # "TEST" -> browser tab title "TEST Kandev" database: driver: "sqlite" # "sqlite" or "postgres" diff --git a/docs/public/configuration.md b/docs/public/configuration.md index 962feceb7c..3de49b8782 100644 --- a/docs/public/configuration.md +++ b/docs/public/configuration.md @@ -58,6 +58,7 @@ Some common camelCase keys have explicit compatibility aliases. Use the document | `server.readTimeout` | `KANDEV_SERVER_READTIMEOUT` | `30` | HTTP read timeout in seconds. | | `server.writeTimeout` | `KANDEV_SERVER_WRITETIMEOUT` | `30` | HTTP write timeout in seconds. | | `server.webInternalUrl` | `KANDEV_WEB_INTERNAL_URL` | empty | Development reverse-proxy target for a separately running web app. Installed releases normally serve embedded assets. | +| `server.webTitlePrefix` | `KANDEV_WEB_TITLE_PREFIX` | empty | Prefixes the browser tab title as `<prefix> Kandev` (for example `TEST` renders `TEST Kandev`), so several instances stay distinguishable in adjacent tabs. Empty keeps the plain `Kandev` title. | The default host exposes the server on every interface even though the CLI prints a `localhost` URL. The current local product path must not be treated as an authenticated multi-user perimeter. For remote access, bind to loopback and use a trusted authenticated tunnel/proxy, or isolate the network at the deployment layer. @@ -212,6 +213,7 @@ server: readTimeout: 30 writeTimeout: 30 webInternalUrl: "" + webTitlePrefix: "" database: driver: "sqlite"