Files
kjol/go/cmd/kjol-web/internal/handlers/public.go

118 lines
4.7 KiB
Go

// Package handlers serves the server-rendered public pages of the Kjøl JS Web
// section.
//
// This file is the APP side of a coupling inversion. kjol's bundler renders each
// public page at build time and generates public_pages.gen.go — a list of routes,
// titles, baked HTML, and (for dynamic pages) the render bundle. It does not know
// what a page is served as: no document shell, no stylesheet paths, no data. That
// is all here, because all of it is the application's business.
//
// The generated file declares `var publicPages = []publicPage{...}` and nothing
// else. The TYPE is ours — which is what lets the shape of a page be an app concern
// while the rendering of one stays the framework's.
package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"kjol/jsbundler"
"kjol/webui"
)
// publicPage is the app-side shape the generated registry is written against.
// Field names and order are the generator's contract (jsbundler/genssr.go).
type publicPage struct {
route string // URL path, e.g. "/js/ssr"
title string // <title> text
module string // page module relative to frontend/src (informational)
component string // exported body component name (informational)
html string // pre-rendered, data-free page body (PublicLayout + page content)
renderJS string // bundled render entry; baked ONLY for dynamic (ISR) pages
}
// buildInfo is the payload injected into the /js/ssr page. It mirrors the
// `BuildInfo` interface the component reads via serverData<T>() — the two have to
// agree, and the JSON tags are the whole of that agreement.
type buildInfo struct {
RenderedAt string `json:"renderedAt"`
Stage string `json:"stage"`
}
// RegisterPublicPages binds every generated public page to its route.
//
// A page with a render bundle is rendered PER REQUEST with live data (the ISR
// path). A page without one serves the skeleton that was baked at build time. Both
// ship complete HTML; the difference is only whether the numbers in it are fresh.
func RegisterPublicPages(mux *http.ServeMux) {
for _, p := range publicPages {
mux.HandleFunc("GET "+p.route, servePublicPage(p))
}
}
func servePublicPage(p publicPage) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body := p.html
data := ""
// The ISR path. The SAME Solid component that was baked at build time is run
// again here, in goja, with data injected — so the server's markup is not a
// template with holes punched in it, it is the component's own output.
if p.renderJS != "" {
payload, err := json.Marshal(buildInfo{
RenderedAt: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
Stage: "request time, in goja",
})
if err != nil {
log.Printf("public page %s: marshalling data: %v", p.route, err)
} else if rendered, err := jsbundler.RenderBundleWithData(p.renderJS, string(payload)); err != nil {
// Fall through to the baked skeleton rather than 500. A page that cannot
// render with data is still a page; serving nothing helps no one.
log.Printf("public page %s: ISR render failed, serving skeleton: %v", p.route, err)
} else {
body, data = rendered, string(payload)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, document(p.title, body, data))
}
}
// document wraps a rendered body in the page shell.
//
// __SERVER_DATA__ is inlined BEFORE the bundle, and it is the same JSON the server
// just rendered with. That is what makes the client takeover silent: public.tsx
// re-renders the identical component against the identical data and produces the
// identical markup, so the swap is invisible. Omit it and the page would render, then
// visibly collapse back to its loading skeleton the moment the bundle loaded.
func document(title, body, data string) string {
serverData := ""
if data != "" {
serverData = "\n<script>window.__SERVER_DATA__ = " + data + ";</script>"
}
// webui.ThemeBootScript is the Go/WASM kit's — reused verbatim, because it reads the
// same "kjol-theme" key the Solid kit's controller writes. One script, one key, and a
// reader's choice of theme survives crossing between two front-ends that share
// nothing else. It goes BEFORE the stylesheet, or a dark-mode reader gets a white
// page until the CSS lands.
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>` + title + `</title>
` + webui.ThemeBootScript + `
<link rel="stylesheet" href="/public.bundle.min.css" />
</head>
<body class="antialiased">
<div id="page-root">` + body + `</div>` + serverData + `
<script type="module" src="/public.bundle.min.js"></script>
</body>
</html>`
}