package webapp import ( "bytes" "encoding/json" "fmt" "html" "io/fs" "strings" "github.com/kandev/kandev/internal/i18n" ) const bootPayloadGlobal = "window.__KANDEV_BOOT_PAYLOAD__" const debugGlobalAssignment = "window.__KANDEV_DEBUG=true;" const maxInt = int(^uint(0) >> 1) var headCloseTag = []byte("") var htmlOpenTag = []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 // NormalizeLocale returns locale if it is supported, otherwise DefaultLocale. // Locale support lives in internal/i18n so `` and message lookup can // never disagree. func NormalizeLocale(locale string) string { return i18n.Normalize(locale) } // RenderShell reads indexPath from assets and injects the boot payload before // . The assets FS can be an embedded Vite dist in production or an // in-memory filesystem in tests. func RenderShell(assets fs.FS, indexPath string, payload BootPayload) ([]byte, error) { indexHTML, err := fs.ReadFile(assets, indexPath) if err != nil { return nil, fmt.Errorf("read web shell: %w", err) } return RenderShellHTML(indexHTML, payload) } // RenderShellHTML injects the boot payload into an already-loaded HTML shell. // Dev mode uses this with Vite's in-memory index.html. func RenderShellHTML(indexHTML []byte, payload BootPayload) ([]byte, error) { script, err := BootPayloadScript(payload) if err != nil { return nil, err } localized := rewriteHTMLLang(indexHTML, NormalizeLocale(payload.Runtime.Locale)) 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 // to locale so first paint matches the SPA's active locale. If the tag has no // lang attribute, one is inserted. Only the opening tag is touched. func rewriteHTMLLang(indexHTML []byte, locale string) []byte { start := bytes.Index(indexHTML, htmlOpenTag) if start < 0 { return indexHTML } end := bytes.IndexByte(indexHTML[start:], '>') if end < 0 { return indexHTML } end += start tag := indexHTML[start : end+1] langAttr := []byte(` lang="` + locale + `"`) out := make([]byte, 0, len(indexHTML)+len(langAttr)) out = append(out, indexHTML[:start]...) out = append(out, replaceLangInTag(tag, locale, langAttr)...) out = append(out, indexHTML[end+1:]...) return out } func replaceLangInTag(tag []byte, locale string, langAttr []byte) []byte { idx := bytes.Index(tag, []byte(` lang="`)) if idx < 0 { // No lang attribute: insert one right after "', and '&', which prevents a state value // from closing the script tag. func BootPayloadScript(payload BootPayload) ([]byte, error) { data, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("marshal boot payload: %w", err) } debugPrefix := "" if payload.Runtime.Debug { debugPrefix = debugGlobalAssignment } script := make([]byte, 0, bytesCapacity(len(data), len(debugPrefix), len(bootPayloadGlobal), 32)) script = append(script, ""...) return script, nil } func injectBeforeHeadClose(indexHTML, script []byte) []byte { idx := bytes.Index(indexHTML, headCloseTag) if idx < 0 { out := make([]byte, 0, bytesCapacity(len(indexHTML), len(script))) out = append(out, script...) out = append(out, indexHTML...) return out } out := make([]byte, 0, bytesCapacity(len(indexHTML), len(script))) out = append(out, indexHTML[:idx]...) out = append(out, script...) out = append(out, indexHTML[idx:]...) return out } func bytesCapacity(parts ...int) int { total := 0 for _, part := range parts { if part > maxInt-total { return 0 } total += part } return total }