feat: add configurable browser tab title prefix (#2459)

This commit is contained in:
Aulma
2026-08-09 13:02:44 +02:00
committed by GitHub
parent b116449962
commit 14d91f5e3d
16 changed files with 316 additions and 4 deletions
+4 -2
View File
@@ -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 <html lang> 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
@@ -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()
+1
View File
@@ -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,
@@ -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)
}
}
@@ -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 "<prefix> 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")
@@ -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 {
+7
View File
@@ -46,6 +46,13 @@ type RuntimeConfig struct {
// before first paint. Sourced from the kandev_locale cookie; defaults to
// "en". Also drives the shell's <html lang> 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 <title> 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.
+66 -1
View File
@@ -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("</title>")
// 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 "<prefix> 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 <title> 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 </title>, reporting false when either tag is missing. The byte scan
// assumes the shell's first "<title" is the head's title element — the same
// assumption rewriteHTMLLang makes for "<html" — which Vite-generated shells
// satisfy; a "<title" inside an earlier comment or script would fool it.
func titleContentBounds(indexHTML []byte) (start, end int, ok bool) {
tagStart := bytes.Index(indexHTML, titleOpenTag)
if tagStart < 0 {
return 0, 0, false
}
tagEnd := bytes.IndexByte(indexHTML[tagStart:], '>')
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 <html> tag
@@ -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: "<title>TEST Kandev</title>"},
{name: "prefix trimmed", prefix: " TEST ", wantTitle: "<title>TEST Kandev</title>"},
{name: "empty prefix keeps shell title", prefix: "", wantTitle: "<title>Kandev</title>"},
{name: "blank prefix keeps shell title", prefix: " ", wantTitle: "<title>Kandev</title>"},
{
name: "html special characters are escaped",
prefix: `</title><script>alert(1)</script>`,
wantTitle: "<title>&lt;/title&gt;&lt;script&gt;alert(1)&lt;/script&gt; Kandev</title>",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assets := fstest.MapFS{
"index.html": {
Data: []byte(`<!doctype html><html lang="en"><head><title>Kandev</title></head><body></body></html>`),
},
}
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(`<!doctype html><html lang="en"><head></head><body></body></html>`)},
}
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), "<title") {
t.Fatalf("expected no title element to be created, got: %s", html)
}
}
func TestComposeTitle(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ in, want string }{
{"", "Kandev"},
{" ", "Kandev"},
{"TEST", "TEST Kandev"},
{" staging ", "staging Kandev"},
} {
if got := ComposeTitle(tc.in); got != tc.want {
t.Fatalf("ComposeTitle(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestNormalizeLocale(t *testing.T) {
t.Parallel()
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { applyTitlePrefix, composeTitle } from "./document-title";
function fakeDocument(title: string): Document {
return { title } as Document;
}
describe("composeTitle", () => {
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");
});
});
+27
View File
@@ -0,0 +1,27 @@
/**
* Browser tab title composition. Mirrors the Go rule in
* `apps/backend/internal/webapp/shell.go` (`ComposeTitle`): the shell rewrites
* `<title>` 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);
}
+16
View File
@@ -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", () => {
+8
View File
@@ -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),
};
}
+5
View File
@@ -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
+2
View File
@@ -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"
+2
View File
@@ -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"