SummerCMS
diff --git a/admin/src/App.vue b/admin/src/App.vue
index f8877ed7..7b85085b 100644
--- a/admin/src/App.vue
+++ b/admin/src/App.vue
@@ -3,6 +3,7 @@ import { computed, watchEffect } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { documentTitleForRoute } from './app/pageTitle'
import AppShell from './components/shell/AppShell.vue'
+import UpdateBanner from './components/ui/UpdateBanner.vue'
import { remountRevision } from './state/useRemount'
const route = useRoute()
@@ -17,11 +18,13 @@ watchEffect(() => {
+ place (Create and new). The new-version banner sits outside the
+ v-if, so it also shows on the login screen. -->
+
diff --git a/admin/src/app/buildUpdate.ts b/admin/src/app/buildUpdate.ts
new file mode 100644
index 00000000..e3bbec82
--- /dev/null
+++ b/admin/src/app/buildUpdate.ts
@@ -0,0 +1,142 @@
+// New-version check. An admin tab opened before a deploy keeps running the
+// old bundle against the new backend, sometimes for days, because the session
+// refreshes silently; a plugin field type the old bundle does not know then
+// shows as "Unsupported field type". The server (boardwalk) writes the build
+// id into the served index and answers it at {base}/build.json. The tab
+// remembers the build it booted with (runtime.build), re-checks on focus, on
+// visibility and on an interval, and on a different build shows a
+// non-blocking banner (UpdateBanner). It never reloads by itself, so unsaved
+// edits stay where they are.
+import { readonly, ref } from 'vue'
+import { runtime } from './runtime'
+
+/** Re-check interval while the tab is visible: 5 minutes. */
+export const BUILD_CHECK_INTERVAL = 5 * 60 * 1000
+/** Minimum gap between two checks: 30 seconds. */
+export const BUILD_CHECK_MIN_GAP = 30 * 1000
+
+/**
+ * Whether seen is a different build than booted. Ids are not ordered: any
+ * difference is a different deploy, a rollback included. An empty id on
+ * either side is never a new build.
+ */
+export function isNewBuild(booted: string, seen: string): boolean {
+ return booted !== '' && seen !== '' && booted !== seen
+}
+
+/**
+ * The current build from {base}/build.json, or "" when the request fails,
+ * the response is not ok or the body has no string build. It never throws.
+ */
+export async function fetchBuild(): Promise {
+ try {
+ // Resolve fetch per call so tests can replace globalThis.fetch.
+ const response = await globalThis.fetch(`${runtime.base}/build.json`, {
+ cache: 'no-store',
+ credentials: 'same-origin',
+ })
+ if (!response.ok) {
+ return ''
+ }
+ const body: unknown = await response.json()
+ if (typeof body !== 'object' || body === null) {
+ return ''
+ }
+ const build = (body as { build?: unknown }).build
+ return typeof build === 'string' ? build : ''
+ } catch {
+ return ''
+ }
+}
+
+const available = ref(false)
+let latest = ''
+let dismissed = ''
+
+/** True while a build different from the booted one waits for a reload. */
+export const newBuildAvailable = readonly(available)
+
+/**
+ * Records a build seen on the server. A build different from the booted one
+ * shows the banner, unless it is the build the administrator dismissed.
+ */
+export function noteBuild(seen: string, booted: string = runtime.build): void {
+ if (isNewBuild(booted, seen) && seen !== dismissed) {
+ latest = seen
+ available.value = true
+ }
+}
+
+/** Hides the banner until a build different from the current one is seen. */
+export function dismissNewBuild(): void {
+ dismissed = latest
+ available.value = false
+}
+
+/** Clears the module state; for tests. */
+export function resetBuildUpdate(): void {
+ latest = ''
+ dismissed = ''
+ available.value = false
+}
+
+export interface BuildWatchOptions {
+ /** The build the tab booted with; default runtime.build. */
+ booted?: string
+ /** Reads the current build; default fetchBuild. */
+ check?: () => Promise
+ /** Interval between checks while visible; default BUILD_CHECK_INTERVAL. */
+ interval?: number
+ /** Minimum gap between two checks; default BUILD_CHECK_MIN_GAP. */
+ minGap?: number
+ win?: Pick
+ doc?: Pick
+ now?: () => number
+}
+
+/**
+ * Starts the new-version check and returns its stop function. With no booted
+ * build (the served index carries none, as under the dev server) it installs
+ * nothing. A check runs on visibilitychange to visible, on window focus and
+ * on the interval, skipped while the tab is hidden, while a check is in
+ * flight or within minGap of the last check.
+ */
+export function startBuildWatch(options: BuildWatchOptions = {}): () => void {
+ const booted = options.booted ?? runtime.build
+ if (booted === '') {
+ return () => undefined
+ }
+ const check = options.check ?? fetchBuild
+ const interval = options.interval ?? BUILD_CHECK_INTERVAL
+ const minGap = options.minGap ?? BUILD_CHECK_MIN_GAP
+ const win = options.win ?? window
+ const doc = options.doc ?? document
+ const now = options.now ?? Date.now
+ let inFlight = false
+ let lastStart = Number.NEGATIVE_INFINITY
+
+ const run = async (): Promise => {
+ if (doc.visibilityState === 'hidden' || inFlight || now() - lastStart < minGap) {
+ return
+ }
+ lastStart = now()
+ inFlight = true
+ try {
+ noteBuild(await check(), booted)
+ } finally {
+ inFlight = false
+ }
+ }
+ const trigger = (): void => {
+ run().catch(() => undefined)
+ }
+
+ doc.addEventListener('visibilitychange', trigger)
+ win.addEventListener('focus', trigger)
+ const timer = win.setInterval(trigger, interval)
+ return () => {
+ doc.removeEventListener('visibilitychange', trigger)
+ win.removeEventListener('focus', trigger)
+ win.clearInterval(timer)
+ }
+}
diff --git a/admin/src/app/runtime.ts b/admin/src/app/runtime.ts
index 313fc8d3..87a42f43 100644
--- a/admin/src/app/runtime.ts
+++ b/admin/src/app/runtime.ts
@@ -1,8 +1,12 @@
// Runtime configuration read once from the served index.html. The Go server
// (boardwalk) writes the configured backend.uri into the summer-admin-base
// meta; the SPA derives its router base and API base from it (D-02, D-03).
+// It also writes the build id into the summer-admin-build meta, the build the
+// tab booted with (buildUpdate.ts compares it with build.json).
export const DEFAULT_BASE = '/backend'
const TOKEN = '__SUMMER_ADMIN_BASE__'
+const BUILD_TOKEN = '__SUMMER_ADMIN_BUILD__'
+const BUILD_SHAPE = /^[0-9a-f]{6,64}$/
export function readBase(doc: Document = document): string {
const content = doc.querySelector('meta[name="summer-admin-base"]')?.content.trim() ?? ''
@@ -13,6 +17,18 @@ export function readBase(doc: Document = document): string {
return trimmed === '' ? DEFAULT_BASE : trimmed
}
+/**
+ * The build id from the summer-admin-build meta, or "" when the meta is
+ * missing, empty, the unreplaced token (the dev server) or not lowercase hex.
+ */
+export function readBuild(doc: Document = document): string {
+ const content = doc.querySelector('meta[name="summer-admin-build"]')?.content.trim() ?? ''
+ if (content === BUILD_TOKEN || !BUILD_SHAPE.test(content)) {
+ return ''
+ }
+ return content
+}
+
const base = readBase()
export const runtime = {
@@ -20,4 +36,6 @@ export const runtime = {
base,
/** Admin API root, the mount path plus /api/v1. */
api: `${base}/api/v1`,
+ /** The build id this tab booted with; "" turns the version check off. */
+ build: readBuild(),
} as const
diff --git a/admin/src/components/ui/UpdateBanner.vue b/admin/src/components/ui/UpdateBanner.vue
new file mode 100644
index 00000000..8ad29adb
--- /dev/null
+++ b/admin/src/components/ui/UpdateBanner.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
diff --git a/admin/src/main.ts b/admin/src/main.ts
index 3a2d37cf..783e01e3 100644
--- a/admin/src/main.ts
+++ b/admin/src/main.ts
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import App from './App.vue'
import { createAdminRouter } from './app/router'
import { onUnauthorized } from './api/client'
+import { startBuildWatch } from './app/buildUpdate'
import { loadFieldTypes } from './app/fieldTypes'
import { loadStrings } from './app/i18n'
import { expireSession, me } from './state/useAuth'
@@ -37,6 +38,9 @@ async function boot(): Promise {
app.use(router)
await router.isReady()
app.mount('#app')
+ // The new-version check; it stays off when the served index carries no
+ // build (the dev server).
+ startBuildWatch()
}
void boot()
diff --git a/docs/backend/admin-spa.md b/docs/backend/admin-spa.md
index 7b8f5814..009cfe18 100644
--- a/docs/backend/admin-spa.md
+++ b/docs/backend/admin-spa.md
@@ -16,8 +16,10 @@ cabana mounts the SPA under the admin prefix, `backend.uri` (`/backend` by defau
- Paths under `api/` that no API route matches return the admin API's JSON `not_found` error, never the SPA.
- Hashed files under `assets/` are cached for a long time; `index.html` is never cached.
- Every response carries a restrictive Content-Security-Policy (`frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'; form-action 'self'; frame-src 'none'`: scripts only from the admin origin, no native form post to another origin, no framed content), frame denial, `nosniff`, a same-origin referrer policy and `noindex, nofollow`.
+- Every response also carries the build id in the `X-Summer-Build` header (`boardwalk.BuildHeader`), the `api/` misses included.
+- `GET /build.json` answers `{"build":""}` with `Cache-Control: no-store`, so an open tab can ask which build the server runs.
-The build is path-agnostic: `index.html` holds a placeholder (`boardwalk.BaseToken`) that `boardwalk.RewriteIndex` replaces with the prefix once, when the handler is built. A build without the placeholder fails the start-up.
+The build is path-agnostic: `index.html` holds a placeholder (`boardwalk.BaseToken`) that `boardwalk.RewriteIndex` replaces with the prefix once, when the handler is built. A build without the placeholder fails the start-up. A second placeholder, `boardwalk.BuildToken`, is replaced with the build id (`boardwalk.BuildID`: the admin build plus the binary's build information, so every redeploy of a different binary gets a new id); an index without it is served as it is and the SPA's version check stays off.
A plugin route under the admin prefix also fails the start-up: the SPA and the admin API own that whole path.
@@ -39,6 +41,10 @@ The button at the top of the plugin rail switches the theme: each click moves fr
When the server offers more than one admin language (`meta.locales` of `GET .../lang`), the user menu lists them under Language, each named in its own language. Choosing one saves it as the administrator's `locale` preference, caches it in `localStorage` (`summer-admin.locale`) and reloads the page, so the strings, navigation, settings, schemas and validation messages all come back in that language, in any browser and whatever `Accept-Language` says. A form with unsaved changes asks before the reload.
+## New versions
+
+A tab opened before a deploy would otherwise keep running the old admin against the new server, because the session refreshes silently. The SPA reads the build it booted with from the `summer-admin-build` meta of the served `index.html` and asks `/build.json` again when the window gets focus, when the tab becomes visible and every 5 minutes while it is visible, at most once every 30 seconds. When the server answers a different build, a banner at the bottom right says that a new version is available, with Reload and Close buttons. The SPA never reloads by itself: Reload goes through the usual page reload, so a form with unsaved changes still asks first, and Close hides the banner until yet another build is deployed. When the served index carries no build, as under the Vite dev server, the check is off.
+
## Keyboard shortcuts
As in WinterCMS, a shortcut presses a button on the screen. Ctrl and Cmd are both accepted on every platform; the tooltips name Cmd (⌘) on macOS.
diff --git a/modules/boardwalk/README.md b/modules/boardwalk/README.md
index c81004e6..c79906cd 100644
--- a/modules/boardwalk/README.md
+++ b/modules/boardwalk/README.md
@@ -8,7 +8,7 @@ HTTP handler that serves the embedded admin SPA build under a configurable path
## Overview
-`boardwalk` embeds the compiled admin SPA (`dist/`, produced by `npm --prefix admin run build`) into the binary and serves it. The build is path-agnostic: `index.html` carries a placeholder token that the handler replaces once, at construction, with the prefix the admin is mounted under, so one build works at any backend URI. [cabana](../cabana/README.md) mounts it when it activates the admin routes. It stands in for the server-rendered backend layouts of WinterCMS, which the Go port replaces with a single-page app.
+`boardwalk` embeds the compiled admin SPA (`dist/`, produced by `npm --prefix admin run build`) into the binary and serves it. The build is path-agnostic: `index.html` carries a placeholder token that the handler replaces once, at construction, with the prefix the admin is mounted under, so one build works at any backend URI. The handler also computes a build id, so an open admin tab can tell that a new version was deployed. [cabana](../cabana/README.md) mounts it when it activates the admin routes. It stands in for the server-rendered backend layouts of WinterCMS, which the Go port replaces with a single-page app.
## Role in the framework
@@ -29,6 +29,10 @@ Use cases:
- Long-lived immutable caching for hashed files under `assets/`, `no-cache` for other files and `no-store` for `index.html`.
- Security headers on every response: a restrictive Content-Security-Policy (`frame-ancestors 'none'`, `base-uri 'none'`, `object-src 'none'`, `script-src 'self'`, `form-action 'self'`, `frame-src 'none'`), frame denial, `nosniff`, a same-origin referrer policy and `noindex, nofollow`.
- Explicit content types for scripts, styles, fonts, SVG and JSON, with a MIME lookup fallback.
+- A build id (`boardwalk.BuildID`), computed once when the handler is built: 12 hex characters of a sha256 over the raw dist tree plus the binary's build information from `runtime/debug.ReadBuildInfo` (the Go version, the main module path, version and sum, `vcs.revision` and `vcs.modified`, and every dependency's path, version and sum, replacements included).
+- The build id in the `X-Summer-Build` header (`boardwalk.BuildHeader`) on every response the handler writes, the `api/` hand-off included.
+- `{prefix}/build.json` (`boardwalk.BuildPath`) answers `{"build":""}` as `no-store` JSON.
+- The `summer-admin-build` meta in `index.html` gets the build id in place of `boardwalk.BuildToken`; an index without the token is served unchanged and the SPA's version check stays off.
## Usage
@@ -46,6 +50,8 @@ if err != nil {
mux.Handle("/backend/", spa)
```
+The build id changes when any file of the admin build changes and on any redeploy of a different binary: a new commit (`vcs.revision`), a change of `vcs.modified`, a dependency version bump (a plugin release included) or a toolchain change. Without build information the id is the dist hash alone. Plugin admin asset files are not hashed directly; they reach the id through their module versions and the application's revision. A build with `-buildvcs=false` that takes plugins only from local `replace` directives does not see edits inside the replaced modules. An identical rebuild may still produce a new id once, which only shows the reload banner.
+
## API reference
| Identifier | Description |
@@ -54,6 +60,10 @@ mux.Handle("/backend/", spa)
| `boardwalk.Dist` | Returns the embedded build as an `fs.FS` rooted at `dist/`. |
| `boardwalk.RewriteIndex` | Rewrites raw `index.html` bytes for a prefix; errors when the placeholder token is missing. |
| `boardwalk.BaseToken` | The placeholder in `dist/index.html` that is replaced by the prefix. |
+| `boardwalk.BuildID` | Computes the build id from a dist tree and the binary's build information (`nil` for the dist alone). |
+| `boardwalk.BuildHeader` | The response header, `X-Summer-Build`, that carries the build id on every response. |
+| `boardwalk.BuildToken` | The placeholder in the `summer-admin-build` meta of `dist/index.html` that is replaced by the build id. |
+| `boardwalk.BuildPath` | The path under the prefix, `build.json`, that answers the current build id. |
| `boardwalk.ContentType` | The Content-Type served for a file name, with explicit UTF-8 JavaScript and CSS types. Shared with the plugin asset route in cabana. |
| `boardwalk.SetSecurityHeaders` | Sets the admin security headers (nosniff, referrer policy, no framing, the CSP with `script-src 'self'`, `form-action 'self'` and `frame-src 'none'`, noindex) on a response. |
@@ -61,7 +71,7 @@ mux.Handle("/backend/", spa)
- SummerCMS modules: none.
- Third-party: none.
-- Standard library: `bytes`, `embed`, `errors`, `fmt`, `html`, `io/fs`, `mime`, `net/http`, `path`, `strings`, `time`.
+- Standard library: `bytes`, `crypto/sha256`, `embed`, `encoding/hex`, `errors`, `fmt`, `html`, `io`, `io/fs`, `mime`, `net/http`, `path`, `runtime/debug`, `slices`, `strconv`, `strings`, `time`.
The embedded `dist/` tree is generated from the `admin/` Vite project, whose build writes to `modules/boardwalk/dist`.
diff --git a/modules/boardwalk/boardwalk.go b/modules/boardwalk/boardwalk.go
index 76977213..66628183 100644
--- a/modules/boardwalk/boardwalk.go
+++ b/modules/boardwalk/boardwalk.go
@@ -5,18 +5,30 @@
// index.html once for the configured backend.uri, serves hashed assets with
// long-lived caching, falls back to index.html for client-side routes and
// hands every unmatched api/ path back to the caller so API misses stay JSON.
+//
+// The handler also computes one build id when it is built (BuildID: the dist
+// tree plus the binary's build information). Every response carries it in the
+// X-Summer-Build header, {prefix}/build.json answers it, and index.html gets
+// it in the summer-admin-build meta, so an open admin tab can tell that a new
+// version was deployed.
package boardwalk
import (
"bytes"
+ "crypto/sha256"
"embed"
+ "encoding/hex"
"errors"
"fmt"
"html"
+ "io"
"io/fs"
"mime"
"net/http"
"path"
+ "runtime/debug"
+ "slices"
+ "strconv"
"strings"
"time"
)
@@ -27,6 +39,25 @@ var distFS embed.FS
// BaseToken is replaced in dist/index.html by the configured admin prefix.
const BaseToken = "__SUMMER_ADMIN_BASE__"
+// BuildHeader is the response header that carries the build id on every
+// response the handler writes.
+const BuildHeader = "X-Summer-Build"
+
+// BuildToken is the token in dist/index.html's summer-admin-build meta that
+// the handler replaces with the build id. An index without it is served
+// unchanged and the SPA turns its version check off.
+const BuildToken = "__SUMMER_ADMIN_BUILD__"
+
+// BuildPath is the path under the prefix that answers the current build as
+// JSON ({"build":""}, no-store). It wins over a dist file of that name.
+const BuildPath = "build.json"
+
+// buildIDLength matches the length cabana uses for plugin asset versions.
+const buildIDLength = 12
+
+// readBuildInfo reads the binary's build information; tests replace it.
+var readBuildInfo = debug.ReadBuildInfo
+
// contentSecurityPolicy keeps the admin out of frames and allows scripts
// only from its own origin; the build contains no inline script. Injected
// markup can neither post a native form off-origin (form-action 'self') nor
@@ -78,7 +109,94 @@ func newHandler(root fs.FS, prefix string, notFoundAPI http.Handler) (http.Handl
if err != nil {
return nil, err
}
- return &handler{root: root, prefix: prefix, index: index, notFoundAPI: notFoundAPI}, nil
+ // The id is computed once, when the handler is built; it is hex, so it
+ // needs no escaping in the meta.
+ build, err := BuildID(root, buildInfo())
+ if err != nil {
+ return nil, err
+ }
+ index = bytes.ReplaceAll(index, []byte(BuildToken), []byte(build))
+ return &handler{root: root, prefix: prefix, index: index, build: build, notFoundAPI: notFoundAPI}, nil
+}
+
+// buildInfo returns the binary's build information, or nil when it is not
+// available.
+func buildInfo() *debug.BuildInfo {
+ info, ok := readBuildInfo()
+ if !ok {
+ return nil
+ }
+ return info
+}
+
+// BuildID identifies one deploy of the admin: the first 12 lowercase hex
+// characters of a sha256 over
+//
+// - the dist tree, raw (before any prefix rewrite): every regular file in
+// fs.WalkDir order as its slash path, its length and its contents;
+// - then, when info is not nil, the binary's build identity: the Go
+// version, the main module path, version and sum, the vcs.revision and
+// vcs.modified settings when present, and every dependency's path,
+// version and sum (and its replacement's), sorted by path.
+//
+// Other build settings (flags, GOOS and the like) are left out. With info nil
+// the id is the dist hash alone. The same tree with the same info always
+// gives the same id; a changed dist file or a different binary (a new commit,
+// a dependency bump, a toolchain change) gives a new one.
+func BuildID(root fs.FS, info *debug.BuildInfo) (string, error) {
+ sum := sha256.New()
+ err := fs.WalkDir(root, ".", func(name string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if !d.Type().IsRegular() {
+ return nil
+ }
+ body, err := fs.ReadFile(root, name)
+ if err != nil {
+ return err
+ }
+ _, _ = io.WriteString(sum, name)
+ _, _ = sum.Write([]byte{0})
+ _, _ = io.WriteString(sum, strconv.Itoa(len(body)))
+ _, _ = sum.Write([]byte{0})
+ _, _ = sum.Write(body)
+ return nil
+ })
+ if err != nil {
+ return "", fmt.Errorf("boardwalk: build id: %w", err)
+ }
+ if info != nil {
+ writeBuildInfo(sum, info)
+ }
+ return hex.EncodeToString(sum.Sum(nil))[:buildIDLength], nil
+}
+
+// writeBuildInfo writes the deploy-identifying part of info, one line per
+// item, after a NUL-separated marker.
+func writeBuildInfo(w io.Writer, info *debug.BuildInfo) {
+ _, _ = io.WriteString(w, "\x00buildinfo\x00")
+ fmt.Fprintf(w, "go %s\n", info.GoVersion)
+ fmt.Fprintf(w, "main %s %s %s\n", info.Main.Path, info.Main.Version, info.Main.Sum)
+ for _, key := range []string{"vcs.revision", "vcs.modified"} {
+ for _, s := range info.Settings {
+ if s.Key == key {
+ fmt.Fprintf(w, "setting %s=%s\n", s.Key, s.Value)
+ break
+ }
+ }
+ }
+ deps := slices.Clone(info.Deps)
+ slices.SortStableFunc(deps, func(a, b *debug.Module) int {
+ return strings.Compare(a.Path, b.Path)
+ })
+ for _, d := range deps {
+ fmt.Fprintf(w, "dep %s %s %s", d.Path, d.Version, d.Sum)
+ if d.Replace != nil {
+ fmt.Fprintf(w, " => %s %s %s", d.Replace.Path, d.Replace.Version, d.Replace.Sum)
+ }
+ _, _ = io.WriteString(w, "\n")
+ }
}
// RewriteIndex points relative asset URLs at prefix and injects the prefix
@@ -98,11 +216,13 @@ type handler struct {
root fs.FS
prefix string
index []byte
+ build string
notFoundAPI http.Handler
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
SetSecurityHeaders(w.Header())
+ w.Header().Set(BuildHeader, h.build)
rel := strings.TrimPrefix(r.URL.Path, h.prefix)
rel = strings.TrimPrefix(rel, "/")
if rel == "api" || strings.HasPrefix(rel, "api/") {
@@ -110,6 +230,10 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
name := strings.TrimPrefix(path.Clean("/"+rel), "/")
+ if name == BuildPath {
+ h.serveBuild(w)
+ return
+ }
if name == "" || name == "index.html" {
h.serveIndex(w, r)
return
@@ -136,6 +260,14 @@ func (h *handler) serveIndex(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(h.index))
}
+// serveBuild answers the handler's build id as {"build":""}; the id is
+// hex, so it is written without marshalling.
+func (h *handler) serveBuild(w http.ResponseWriter) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ _, _ = io.WriteString(w, `{"build":"`+h.build+`"}`)
+}
+
func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) {
body, err := fs.ReadFile(h.root, name)
if err != nil {
diff --git a/modules/boardwalk/boardwalk_test.go b/modules/boardwalk/boardwalk_test.go
index eaaae5b4..25270545 100644
--- a/modules/boardwalk/boardwalk_test.go
+++ b/modules/boardwalk/boardwalk_test.go
@@ -494,3 +494,39 @@ func TestPhase101BoardwalkExports(t *testing.T) {
}
}
}
+
+// buildFixture is an in-memory dist whose index carries both tokens.
+func buildFixture() fstest.MapFS {
+ return fstest.MapFS{
+ "index.html": &fstest.MapFile{Data: []byte(`` +
+ `` +
+ `` +
+ ``)},
+ "assets/app.js": &fstest.MapFile{Data: []byte(`console.log("acme")`)},
+ }
+}
+
+func TestBuildSmoke(t *testing.T) {
+ root := buildFixture()
+ h, err := newHandler(root, testPrefix, &apiSpy{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ want, err := BuildID(root, buildInfo())
+ if err != nil {
+ t.Fatal(err)
+ }
+ index := get(h, testPrefix+"/")
+ if !strings.Contains(index.Body.String(), ``) {
+ t.Fatalf("index has no build meta %q: %s", want, index.Body.String())
+ }
+ build := get(h, testPrefix+"/"+BuildPath)
+ if build.Body.String() != `{"build":"`+want+`"}` {
+ t.Fatalf("build.json = %s, want %s", build.Body.String(), want)
+ }
+ for _, rec := range []*httptest.ResponseRecorder{index, build} {
+ if got := rec.Header().Get(BuildHeader); got != want {
+ t.Fatalf("%s = %q, want %q", BuildHeader, got, want)
+ }
+ }
+}
diff --git a/modules/phrasebook/backend/lang/en/lang.yaml b/modules/phrasebook/backend/lang/en/lang.yaml
index 5943f14e..9ecdc5dc 100644
--- a/modules/phrasebook/backend/lang/en/lang.yaml
+++ b/modules/phrasebook/backend/lang/en/lang.yaml
@@ -543,3 +543,6 @@ permissions:
manage_users_comment: "Create, edit, deactivate and delete administrator accounts."
manage_roles: "Manage roles"
manage_roles_comment: "Create and edit roles and the permissions they grant."
+version:
+ available: "A new version of the admin is available."
+ reload: "Reload"
diff --git a/modules/phrasebook/backend/lang/pl/lang.yaml b/modules/phrasebook/backend/lang/pl/lang.yaml
index 40f54378..598c7d88 100644
--- a/modules/phrasebook/backend/lang/pl/lang.yaml
+++ b/modules/phrasebook/backend/lang/pl/lang.yaml
@@ -577,3 +577,6 @@ permissions:
manage_users_comment: "Tworzenie, edycja, dezaktywacja i usuwanie kont administratorów."
manage_roles: "Zarządzanie rolami"
manage_roles_comment: "Tworzenie i edycja ról oraz uprawnień, które nadają."
+version:
+ available: "Dostępna jest nowa wersja panelu."
+ reload: "Odśwież"