Files
summercms/.planning/phases/11.2-ready-to-share-summercms-io-website-and-newsletter-plugin/11.2-RESEARCH.md

90 KiB
Raw Blame History

Phase 11.2: summercms.io Alpha 0.1 landing page on SummerCMS - Research

Researched: 2026-10-01 Domain: Nuxt 4 static site embedded in a SummerCMS Go binary (surf routing, go:embed, docsite build), Postgres 15 verification, nginx + supervisor deploy Confidence: HIGH for the framework surface, the docs build, PG15 and the Nuxt build behaviour (all exercised in this session). MEDIUM for the rome deploy details, because the server was not reachable.

<user_constraints>

User Constraints (from CONTEXT.md)

The 11.2 planner applies D-01 to D-05, D-07, D-09, D-12 and D-23 to D-45. D-06, D-08, D-10 and D-11 are superseded, and D-13 to D-22 belong to Phase 11.3. Both groups are left out below.

Locked Decisions

Repos and layout (2026-09-29, still in force)

  • D-01: Three new repos follow the sm- naming convention (oc-/wn- in October/Winter):

    • sm-summercmsio-app is the root app. It builds the binary, holds the other pieces and wires the plugins.
    • vue-summercmsio-app is the Nuxt 4 site, held inside the root app as vue-fonoteka-app is in the fonoteka project.
    • sm-summercmsio-plugin is the site plugin. It holds everything specific to summercms.io: serving the embedded Nuxt build at /, mounting the 11.1 docs output at /docs, and any site-specific data or routes. This is the proven WinterCMS pattern from figs.org.pl (figs/website) and golem15.com.

    Public static-site serving goes in sm-summercmsio-plugin, not in a new framework module. (The fourth repo, sm-newsletter-plugin, moved to 11.3.) — Reversibility: costly — repo names and module paths are referenced by the go.work/replace wiring and every import.

  • D-02: Go module paths live under git.golem15.com/golem15/, like the framework (git.golem15.com/golem15/summercms): for example git.golem15.com/golem15/sm-summercmsio-plugin. — Reversibility: one-way — a published Go module path is baked into every importer.

  • D-03: The root app wires the framework and the site plugin the way fonoteka.go does today (a go.work workspace with a local replace during development).

Website build and hosting (2026-09-29, still in force)

  • D-04: The site is Nuxt 4 with nuxt generate (static output), the same stack as vue-fonoteka-app. Every route is real prerendered HTML, so link previews and search engines see content.
  • D-05: The generated site and the 11.1 docs output are embedded in the Go binary with go:embed. One binary serves / and /docs. The build order (nuxt generate, then summer docs:build, then go build) is documented and scripted in the root app.
  • D-07: The site's served responses must be indexable. The admin-only boardwalk behaviour (noindex, nofollow, admin CSP, base-path rewrite) must not apply to the public site. The site plugin sets its own cache headers: immutable for hashed assets, no-cache for HTML.

Landing page content (2026-09-29)

  • D-09: The "From WinterCMS" section carries the seasons lineage: it started in October (OctoberCMS), went through Winter (WinterCMS), and now it's time for Summer. The handoff's copy is final. The theme lives in the brand (sun, "A new dawn", "Something bright is here") and is not added as extra copy.
  • D-12: A "Built by Golem15" credit links to golem15.com. It is an addition to the handoff's footer, styled as a footer link.

Design handoff

  • D-23: The handoff in design/ (README.md, SummerCMS Landing.dc.html) is high fidelity. Colors, type, spacing, copy, breakpoints and the two interactions (scroll-spy and copy-to-clipboard) are matched exactly. The only copy deviations are those decided here: D-12 (credit), D-39 (clone line) and D-26 (the Postgres chip).

Database at runtime

  • D-24: The binary keeps the stock summer serve path, which opens Postgres unconditionally (modules/surf/serve.go:40). There is no framework change to make the DB optional. rome already runs PostgreSQL 15.19 (Debian 12).
  • D-25: Verify the framework on PostgreSQL 15. Run the framework's database test suites (lagoon, conga and the rest that use postgres:16-alpine) against a postgres:15 image. If they pass, change the documented requirement from "PostgreSQL 16" to "PostgreSQL 15 or newer" in the root README.md, docs/setup/installation.md and any other page that states it (the docs checker must stay green). If any test fails on 15, stop and ask the user. Do not quietly work around it or upgrade rome.
  • D-26: The "PostgreSQL 16" chip in the Get started section follows D-25 and reads "PostgreSQL 15+".
  • D-27: The app uses a dedicated Postgres role (summercms) that owns a dedicated database (summercms_io). The password lives in the app's config or env on the server, never in git. DEPLOY.md documents createuser/createdb, and every deploy runs the app's migrate command before restarting.

Server and deploy flow

  • D-28: The binary is built locally for linux/amd64 by a script in sm-summercmsio-app (nuxt generate, then summer docs:build --base-url /docs, then go build), then rsynced to rome with its config. The server needs neither Go nor Node.
  • D-29: /backend and the admin API are not exposed publicly. nginx proxies only / and /docs to the binary and denies the admin paths. Nothing needs administering until 11.3.
  • D-30: TLS uses rome's existing certbot / Let's Encrypt setup. DEPLOY.md shows the nginx server block with the certbot-managed certificate paths, HTTP to HTTPS redirect, gzip and proxy headers.
  • D-31: Cutover replaces the existing summercms.io nginx server block in place. The current "Under construction" block is saved in DEPLOY.md (or alongside it) as the rollback.
  • D-32: The binary runs under supervisor as a dedicated system user on a localhost-only port. The user, port and paths are Claude's discretion and are written down in DEPLOY.md and the supervisor program config.

Design to Nuxt

  • D-33: The site is i18n-ready. @nuxtjs/i18n is installed and configured with the single locale en, and all page copy lives in locales/en.json. 11.3 adds pl.json and a switcher without touching component markup.
  • D-34: Roboto (300/400/500/700) and Roboto Mono (400/500) are self-hosted through @nuxt/fonts, the same module as vue-fonoteka-app. They are downloaded at build time and embedded, so visitors make no request to Google.
  • D-35: Styles are plain CSS. The handoff tokens are CSS custom properties on :root, used by scoped component styles. No Tailwind.
  • D-36: The sun artwork is the original from the live site, http://summercms.io/logo.png (746×744 transparent RGBA PNG, 513 KB). It replaces the handoff's sun-crop.png placeholder. Resized and compressed copies are made for the hero badge (180px), the header logo (30px), the favicon set and the OG image.
  • D-37: SEO uses the full @nuxtjs/seo module, as vue-fonoteka-app does: meta, Open Graph and Twitter tags, OG image, schema.org, sitemap and robots. Whatever it generates must work with nuxt generate and be embedded (no runtime OG rendering in the Go binary).
  • D-45: The prototype's showRays and scrollSpy flags become Nuxt app config, both defaulting to true.

Get started accuracy

  • D-38: Both Source links point to https://git.golem15.com/golem15/summercms. The repo currently requires login, so making it public is a launch checklist item in DEPLOY.md, done manually by the user before cutover. The link check (roadmap criterion 3) verifies anonymous access at cutover time.
  • D-39: The terminal card gets a clone step so it works when pasted into a fresh shell: a # get the framework comment, then $ git clone https://git.golem15.com/golem15/summercms and $ cd summercms, before the existing go install ./cmd/summer group. The Copy button copies every command line in order (now six), without $ or comments.
  • D-40: A scripted check proves the terminal commands work verbatim. It runs them in a temp clone with a temp GOBIN, ending with ./bin/hello greeter:hello. If that command needs a database or other setup, the check provides it, and the copy is not changed again without asking. The command list lives in one place shared by the page and the check, or the check asserts that the two match, so they cannot drift.

Docs and release

  • D-41: Framework change: an optional site_url key in docs/site.yaml adds a link back to the main site in the docs header (internal/docsite/theme/templates/header.html), for example "← summercms.io". When the key is unset, the output is unchanged. The change also updates the docs page that describes site.yaml, and summer docs:build --check and TestDocsTree stay green. summercms.io sets it to /.
  • D-42: The framework is tagged v0.1.0 (Alpha 0.1). sm-summercmsio-app requires git.golem15.com/golem15/summercms v0.1.0 (with the local replace during development), and the embedded docs are built from that tag, so the page, the docs and the code agree. The user confirms before the tag is created and pushed. — Reversibility: one-way — a pushed Go module version tag is cached by proxies and cannot be reused.
  • D-43: The new repos live as siblings of summercms.go in the meta repo directory (summercms/sm-summercmsio-app/, like fonoteka.go). vue-summercmsio-app and sm-summercmsio-plugin are submodules inside sm-summercmsio-app. Creating the remotes on git.golem15.com is a manual step the plan lists for the user.
  • D-44: Every link on the page is verified against the built docs output (roadmap criterion 3), not assumed. As of 2026-10-01 all ten /docs/... targets in the handoff exist as pages in docs/.

Claude's Discretion

  • The system user, port and filesystem paths on rome (D-32).
  • Whether the Nuxt build and the docs are embedded by sm-summercmsio-plugin or by the root app, and how the plugin mounts /docs.
  • The 404 handling for unknown paths (for example Nuxt's generated 404.html with a 404 status).
  • Image formats and sizes derived from the sun artwork, and the OG image layout within the brand.
  • Where the D-40 command check lives (root app script or test) and how it shares the command list with the page.
  • How prefers-reduced-motion affects smooth scrolling.

Deferred Ideas (OUT OF SCOPE)

  • Composing and sending newsletters, templates and the campaign CLI, ported from the PHP plugin in a later phase. That phase also merges confirmed subscribers with the user-based audience.
  • CSV export of subscribers. It waits for a framework export or toolbar capability.
  • Renaming fonoteka.go to sm-fonoteka-app is a separate task.
  • Translating the docs into Polish.
  • go install git.golem15.com/golem15/summercms/cmd/summer@latest as the install command. This needs the module path to be go-gettable (a public repo plus go-import meta).
  • A CI pipeline that builds and publishes the binary, instead of local build and rsync.
  • Making the database optional in summer serve, for apps without data.
  • (Also out of scope: newsletter signup, the Polish locale and the subscriber admin, all in Phase 11.3.) </user_constraints>

<session_choices>

Choices made in this planning session (orchestrator-provided, binding for the planner)

  • Plan count is fixed at 3 plans:
    • 01: the Nuxt site (vue-summercmsio-app).
    • 02: framework tweaks (PG15 verification and docs, the optional site_url, the v0.1.0 tag checkpoint), then sm-summercmsio-plugin, sm-summercmsio-app, the build script, the D-40 command check, the link check, and DEPLOY.md with the nginx and supervisor configs.
    • 03: unit tests.
  • No UI-SPEC.md. The design/ handoff (README.md and SummerCMS Landing.dc.html) is the design contract (D-23). </session_choices>

<phase_requirements>

Phase Requirements

No requirement IDs are mapped (ROADMAP says "Requirements: TBD"). The five ROADMAP success criteria are the acceptance contract:

ID Success criterion (ROADMAP § Phase 11.2) Research support
SC1 nuxt generate in vue-summercmsio-app produces the landing page matching the handoff, with a sticky header and scroll-spy, a hero, the Why, Features, From WinterCMS and Get started sections, a terminal card with a working Copy button, and a footer. It is responsive down to phone width, and the section links hide at 720px or less. §Design handoff inventory; Nuxt pattern 1; spike-verified nuxt.config.ts (Pitfalls 1–6)
SC2 One sm-summercmsio-app binary embeds the Nuxt output and the 11.1 docs build, and serves / and /docs with indexable responses (no admin noindex/CSP). Cache headers are immutable for hashed assets and no-cache for HTML. §Framework surface; Pattern 2 (site handler); cache table; Pitfalls 7–11
SC3 Every link on the page resolves, including the eight feature-tile /docs/... links and the WinterCMS concept-map link, verified against the built docs. All ten targets verified against a real docs:build --base-url /docs; the .html URL finding; link-check design
SC4 A scripted build (nuxt generate, summer docs:build, go build) and a DEPLOY.md cover building, uploading, a supervisor program config and an nginx site config (TLS, proxy, gzip). Following them on a clean server brings the site up. §Build script; §DEPLOY.md content requirements; boot config requirements (Pitfall 12)
SC5 The new Go code has unit tests, delivered in the last plan. §Validation Architecture
</phase_requirements>

Project Constraints (from CLAUDE.md)

  • Standard library first (net/http ServeMux, html/template, encoding/json). A dependency is added only when the research doc or a phase decision names it. This phase needs no new Go dependency. The site plugin is stdlib plus framework modules.
  • Plugins are compiled Go modules registered at build time. No runtime plugin loading.
  • go vet and go test ./... stay green at every commit, in every repo (framework, app, plugin).
  • Unit tests go in the last plan of the phase (plan 03). Earlier plans may carry smoke tests.
  • Commits: never add co-author tags. One logical change per commit. Planning docs and code go in separate commits.
  • Docs rule: a change to exported API, config keys or CLI commands also updates the module README and the affected docs/ pages in the same change. go test ./cmd/summer -run TestDocsTree and summer docs:build --check must stay green. D-41 adds a site.yaml key (and, as recommended below, a docs:build flag), so docs/console/utilities.md changes in the same commit.
  • Framework docs never name a consuming application. The D-41 docs example uses a neutral URL (https://acme.example/), not summercms.io. The forbidden-name checker is (?i)fonoteka|p[lł][yý]tarium [VERIFIED: internal/docsite/check_forbidden.go:14], so "summercms.io" would pass the checker, but the CLAUDE.md rule still applies.
  • Config keys named in docs are not checked automatically (deferred in 11.1). Review the new site_url key mentions by hand.
  • Core plugin contracts (user, blog, pages, payment) are unaffected by this phase.
  • Global user rule: core plugins must not get breaking changes. This phase touches none.

Summary

Everything the phase needs already exists in the framework, apart from the two small D-41 and D-25 edits. A site plugin registers GET / and GET /docs/{path...} in a raw group on the surf router. These were exercised against the real surf.Assemble. Go's ServeMux gives the catch-all / the lowest precedence, so it does not conflict with any cabana admin pattern. /docs is redirected to /docs/ automatically. POST / returns 405. When the site plugin declares no admin controllers, cabana does not activate, so no /backend routes exist at all and no admin.jwt.secret is needed. summer serve still opens Postgres unconditionally. It also needs app.key, storage.uploads.bucket_url and numeric http.body_limits, so the app ships those configs, and the database password goes in a server-side .env.

Five findings change the plan:

  1. Docs pages are <path>.html files, not directories. The handoff's /docs/backend/admin-spa resolves only if the site handler maps extension-less paths to .html. The page links should use the canonical .html form.
  2. nuxt generate fails (exit 1, "Exiting due to prerender errors") on any /docs link unless nitro.prerender.ignore: ['/docs'] is set. This was reproduced and the fix confirmed.
  3. Nuxt writes _nuxt/, _fonts/, _i18n/ and _payload.json. go:embed drops names that start with _ unless the pattern uses all:.
  4. Docs assets are not content-hashed (/docs/assets/site.css), and neither is /_nuxt/builds/latest.json. "Immutable for hashed assets" therefore means only /_nuxt/* (excluding builds/) and /_fonts/*. Everything else gets no-cache with an ETag.
  5. The DB suites pass on PostgreSQL 15. All nine testcontainers harnesses were run against postgres:15 (15.16) from a scratch export of HEAD. Every package passed, so D-25's stop-and-ask branch is not expected to trigger. Plan 02 still re-runs the check formally.

The D-40 terminal commands were run verbatim in a fresh local clone with a temp GOBIN. ./bin/hello greeter:hello needs no database and printed name=hello-app ... handled=true, exit 0. The only blocker for running the commands exactly as written is the git clone URL. https://git.golem15.com/golem15/summercms returns 404 to anonymous users and git ls-remote asks for credentials (verified). So the check needs a clone-source override until the repo is public, and it runs verbatim at cutover.

Primary recommendation:

  • Embed all:public in sm-summercmsio-plugin. The build script fills public/site (Nuxt) and public/docs (docs from a git archive v0.1.0 export), and a placeholder public/README.md is committed.
  • Serve both trees with one stdlib handler that has its own MIME table, extension-less .html resolution, 404 pages with a 404 status, and per-path cache rules.
  • Keep the Nuxt config exactly as spike-verified below, with versions pinned to vue-fonoteka-app's lockfile.

Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
Landing page markup, copy, styles, scroll-spy, copy-to-clipboard Browser / Client (Nuxt SSG output) Build time (nuxt generate prerender) Static HTML prerendered at build time; the two interactions are client-only JS.
SEO metadata, sitemap, robots, schema.org Build time (@nuxtjs/seo during prerender) — Emitted as static files and tags; nothing renders at runtime (D-37).
Fonts Build time (@nuxt/fonts downloads to /_fonts) CDN/Static (served by the binary) No Google request at runtime (D-34).
Docs site Build time (summer docs:build from the v0.1.0 tag) Static (served by the binary) Deterministic output: two builds were byte-identical.
Serving / and /docs, cache headers, MIME types, 404s API / Backend (Go binary, sm-summercmsio-plugin handler on the surf mux) — One binary (D-05); the plugin owns site specifics (D-01).
TLS, gzip, HTTP→HTTPS, www→apex, denying /backend, method restriction Reverse proxy (nginx on rome) — D-29/D-30; the binary listens on loopback only.
Process lifecycle OS (supervisor) — D-32.
Persistence Database (Postgres 15 summercms_io) — Required by serve (D-24); only framework tables (system_files, backend admin, queue) are migrated.

Standard Stack

Core (frontend: vue-summercmsio-app)

Versions are pinned to what vue-fonoteka-app runs today. Read from its node_modules this session, and the same versions were installed and built in the spike.

Library Version Purpose Why Standard
nuxt 4.4.8 (latest 4.5.2) SSG via nuxt generate D-04; fonoteka-proven [VERIFIED: fonoteka node_modules + spike build]
@nuxt/fonts 0.14.0 (= latest) Self-host Roboto / Roboto Mono D-34 [VERIFIED: spike downloaded fonts to /_fonts, no googleapis/gstatic in HTML]
@nuxtjs/i18n 10.4.0 (latest 10.6.0) Single en locale, i18n-ready D-33 [VERIFIED: spike]
@nuxtjs/seo 5.2.1 (latest 5.3.16) meta/OG/Twitter, schema.org, sitemap, robots, link checker D-37 [VERIFIED: spike output]
vue 3.5.35 — Peer of nuxt
typescript (dev) ~5.9.0 Type checking Mirror fonoteka. Do not take TS 7.0.2 (published 2026-10-01, the Go-native compiler). Nuxt/vue-tsc compatibility is unverified [ASSUMED risk].

Sub-modules resolved inside @nuxtjs/seo 5.2.1 in the spike: @nuxtjs/sitemap 8.6.1, nuxt-og-image 6.5.x, nuxt-schema-org 6.2.0, nuxt-seo-utils 8.2.1, nuxt-site-config 4.0.8, @nuxtjs/robots 6.0.9, nuxt-link-checker 5.0.10. @nuxtjs/seo uses caret ranges, so only a committed pnpm-lock.yaml pins them. Use pnpm install --frozen-lockfile in the build script.

Supporting

Tool Version (local) Purpose When to Use
pnpm 11.3.0 Package manager (fonoteka uses pnpm, lockfile v9) Always. Copy fonoteka's pnpm-workspace.yaml allowBuilds (minus protobufjs, unused here).
Node 22.23.2 (fonoteka .nvmrc 22.22.0, engines >=22.6) Build only node --test runs .ts tests natively. Verified: # pass 1 with no flags.
ImageMagick 7.1.2 (magick, WEBP/ICO/AVIF delegates present) One-off derivation of sun/favicon/OG images Commit the derived images plus the script. No sharp dependency.
Go 1.27.0 App/plugin build, all tests —
Docker + postgres:15 (15.16) and postgres:15-alpine images 29.7.2 D-25 verification and local boot smoke Images already pulled locally.

Alternatives Considered

Instead of Could Use Tradeoff
Static public/og-image.png + ogImage: { enabled: false } nuxt-og-image prerendered (zero-runtime) Adds satori/resvg or Chromium build deps and a component. A static PNG is what fonoteka does, and it satisfies D-37 trivially.
Embedding in the plugin Embedding in the root app plus an init-time setter Keeps the app 100% generated + config, as fonoteka is, but needs a global setter and couples init order. Plugin embed is simpler.
node --test for TS logic vitest vitest is another dependency (latest is 5.x and fonoteka is on 3.x). Built-in node:test is enough for two pure functions.
ImageMagick one-off sharp in devDependencies sharp is flagged SUS (too-new, 0.35.5 published 2026-09-27) and has native binaries. It is not needed when the outputs are committed.

Installation (vue-summercmsio-app):

pnpm add nuxt@4.4.8 @nuxt/fonts@0.14.0 @nuxtjs/i18n@10.4.0 @nuxtjs/seo@5.2.1 vue@3.5.35
pnpm add -D typescript@~5.9.0

Package Legitimacy Audit

Package Registry Age Downloads Source Repo Verdict Disposition
nuxt npm 10+ yrs 2.44M/wk github.com/nuxt/nuxt OK Approved
@nuxt/fonts npm 2+ yrs 967k/wk github.com/nuxt/fonts OK Approved
@nuxtjs/i18n npm 7+ yrs 776k/wk github.com/nuxt-modules/i18n OK Approved
@nuxtjs/seo npm 2+ yrs 127k/wk github.com/harlan-zw/nuxt-seo SUS (reason: too-new, its latest 5.3.16 published 2026-09-12) Approved at the pinned 5.2.1, the version already running in vue-fonoteka-app. Planner: no extra checkpoint is needed if 5.2.1 is pinned, but add one if a newer version is taken.
vue npm 10+ yrs — github.com/vuejs/core SUS (reason: too-new, latest version recently published) Approved at the pinned 3.5.35 (fonoteka-proven)
typescript npm 12+ yrs — github.com/microsoft/TypeScript OK Approved at ~5.9.0
sharp npm — 123M/wk github.com/lovell/sharp SUS (too-new) Not used. ImageMagick one-off instead.

No postinstall script on nuxt, @nuxt/fonts, @nuxtjs/i18n, @nuxtjs/seo or vue (npm view <pkg> scripts.postinstall printed nothing). Packages removed due to [SLOP] verdict: none. Packages flagged as suspicious [SUS]: @nuxtjs/seo and vue. Both are flagged only because a recent latest release exists. The recommended pinned versions are the ones already in production use in vue-fonoteka-app. If the planner takes newer versions, add a checkpoint:human-verify before install. Go: no new modules. The plugin and app import only stdlib and git.golem15.com/golem15/summercms/modules/....

Framework Surface (question 1)

How a plugin registers routes on the surf mux

  • Plugins implement pact.HasRoutes, Routes(Router) error [VERIFIED: modules/pact/capabilities.go:76-78]. pact.Router has Group, GroupRaw, Get, Post, Put, Patch, Delete, Where and WhereIn [VERIFIED: capabilities.go:63-73].
  • Each route is registered on a stdlib ServeMux as mux.Handle(rt.method+" "+rt.path, h) [VERIFIED: modules/surf/router.go:366]. Every route therefore has a method pattern, and Go 1.22+ precedence rules apply. A ServeMux conflict panic is turned into a boot error, surf: route conflict for ... (router.go:359-368).
  • Non-raw routes are wrapped in recoverJSON (house JSON 500). Raw groups are wrapped in recoverBare [VERIFIED: router.go:418-422]. Use GroupRaw for static serving. Both buffer the whole response before committing it (bufferedResponse), which is fine for files of a few hundred KB.
  • Body limits come from config: http.body_limits.default_bytes and http.body_limits.upload_bytes must be present and numeric whenever app.Config is non-nil, or boot fails with surf: config %s is required [VERIFIED: router.go:466-476, 561-565].

Catch-all conflicts (verified by running code)

A probe plugin registered GroupRaw("", nil, ...) with Get("/") and Get("/docs/{path...}") through the real surf.Assemble(nil, plugins). Results:

Request Result
GET / 200 → site handler, r.URL.Path="/"
GET /x/y.css 200 → site handler (catch-all)
GET /docs 307, Location: /docs/ (ServeMux trailing-slash redirect)
GET /docs/ 200 → docs handler, PathValue("path")=""
GET /docs/setup/installation.html 200 → docs handler
HEAD / 200 (GET patterns also match HEAD)
POST / 405 Method Not Allowed
GET /backend 200 → site catch-all, because no admin is active

The same probe registered GET /, GET /docs/{path...} and cabana's real patterns (GET /backend, GET /backend/{path...}, GET /backend/assets/{vendor}/{plugin}/{file...}, POST /backend/api/v1/auth/login, GET /backend/api/v1/{vendor}/{plugin}/{controller}) on one ServeMux. No conflict panic. GET / is the least specific pattern [VERIFIED: probe test run this session].

  • cabana activates only when some plugin declares admin controllers. if len(items) == 0 { return nil, nil } means no admin routes and no secret check [VERIFIED: modules/cabana/http.go:65-74]. The site plugin must not implement pact.HasAdminControllers (the make:plugin stub does, so do not use the stub as-is).
  • checkAdminPrefix fails boot when a non-cabana route sits at or under backend.uri. It runs only when admin is active (router.go:526-534, 546-559). A catch-all / is not "under" /backend, so it would pass in 11.3 too.
  • A /docs → /docs/ hop: the handoff links /docs from three places. Either register an explicit GET /docs that 301s to /docs/, or let ServeMux's 307 stand. Recommend the explicit 301 (permanent, cacheable), or link /docs/ directly. Both resolve.

boardwalk: what to copy and what not to (D-07)

Copy the idea, not the code:

  • Serve from an fs.FS with http.ServeContent (boardwalk.go:132, 147).
  • Use an explicit content-type table before the mime fallback (boardwalk.go:34-43, 153-162).
  • Set immutable cache only on a known hashed prefix: if strings.HasPrefix(name, "assets/") { "public, max-age=31536000, immutable" } else { "no-cache" } [VERIFIED: modules/boardwalk/boardwalk.go:142-146].

Do not copy:

  • SetSecurityHeaders sets X-Robots-Tag: noindex, nofollow, Content-Security-Policy: frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self' and X-Frame-Options: DENY [VERIFIED: boardwalk.go:32, 167-173]. Nuxt emits inline scripts, so script-src 'self' would break hydration.
  • RewriteIndex (__SUMMER_ADMIN_BASE__ token).
  • The SPA fallback to index.html for unknown paths. The public site wants real 404s.
  • serveIndex's no-store.

summer build and summer.yaml

  • summer.yaml has module, binary and an ordered plugins: [{id, module}] list [VERIFIED: internal/build/manifest.go:18-28; examples/hello/summer.yaml]. Plugin IDs must match ^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$ [VERIFIED: manifest.go:16]. golem15.summercms is valid. Binary names allow letters, digits, ., _ and - (manifest.go:149-165). summercms-io is valid.
  • summer build writes main.go and plugins.gen.go (only when changed), checks the models leaf, then runs go build -o bin/<binary> . with GOWORK set to the nearest go.work (build.go:19-77; scaffold.go:519-541). It inherits the environment, so GOOS/GOARCH pass through. Rome and the local host are both linux/amd64.
  • The generated main.go registers lagoon.RuntimeCommands (migrate, migrate:rollback, migrate:status, key:generate), conga.RuntimeCommands, surf.ServeCommand, surf.RouteListCommand, cabana.RuntimeCommands and plugin HasCommands [VERIFIED: build.go:114-123; lagoon/commands.go:16-75]. D-27's migrate command is ./bin/summercms-io migrate (summer migrate delegates to the same binary, cmd/summer/runtime.go:16-26).
  • compass.Load("config") is relative to the working directory (build.go:105). Supervisor's directory= must therefore be the app dir. SUMMER_ENV defaults to production. A .env next to config/ supplies KEY=VALUE lines not already in the environment [VERIFIED: modules/compass/README.md:90-97].
  • summer plugin:add <dir> adds the plugin to summer.yaml, runs go mod edit -require=<mod>@v0.0.0 -replace=<mod>=./<rel> and go work use <dir> (scaffold.go:106-170, 318-370; docs/console/scaffolding.md:34). It reads the plugin ID from the plugin's ID() return literal (scaffold.go:284-316). Use it after creating the plugin by hand, because make:plugin would set the module path to <app module>/plugins/<name> (scaffold.go:75), not git.golem15.com/golem15/sm-summercmsio-plugin.

fonoteka.go reference wiring (D-03)

  • go.work: go 1.27.0, toolchain go1.27.0, use ( . ./plugins/golem15/user ./plugins/golem15/fonoteka ). It does not use the framework.
  • App go.mod: replace git.golem15.com/golem15/summercms => ../summercms.go, require git.golem15.com/golem15/summercms v0.0.0, plus replaces for the in-tree plugins.
  • Plugin go.mod: replace git.golem15.com/golem15/summercms => ../../../../summercms.go. That is four levels up from fonoteka.go/plugins/golem15/user.
  • Config: config/{app,http,storage,database,queue,...}.yaml. database.yaml has dsn: "" (env-provided). app.yaml has key: "" with the comment "Set SUMMER_APP__KEY". http.yaml has body_limits. storage.yaml has uploads.bucket_url: "file://./storage/app/uploads". queue.yaml has work_in_serve: true.
  • .gitignore: /bin/, /tmp/, go.work.sum.

For sm-summercmsio-app with the plugin submodule at plugins/golem15/summercms, the plugin's framework replace is ../../../../summercms.go. That is the same depth as fonoteka, and it resolves to the meta-repo sibling summercms/summercms.go.

Postgres-at-boot config the app must ship (D-24)

serve runs lagoon.OpenFromApp, which needs database.dsn and a valid app.key (base64 of exactly 32 bytes; empty or invalid fails) [VERIFIED: lagoon/connection.go:77-90; lagoon/encrypted.go:179-207]. It then runs publishUploads, which fails on an empty storage.uploads.bucket_url [VERIFIED: lagoon/attach/bucket.go:66-69], then Assemble (body limits), then conga.StartServeWorker. The worker defaults to workInServe: true [VERIFIED: modules/conga/client.go:39, 55-57] and opens a LISTEN pool. Set queue.work_in_serve: false, because the site has no jobs. migrate creates system_files, backend admin and queue tables (lagoon/migrations.go:63-91). That is harmless and is what D-27's "migrate before restart" runs.

Docs Build (question 2)

  • Output layout (real build: go run ./cmd/summer docs:build --base-url /docs --out <tmp> wrote 72 pages, 164 files, 3.4 MB in 2.5 s): index.html, index.md, 404.html, llms.txt, llms-full.txt, search-index.json, assets/{site.css,site.js,search.js,theme-init.js,fonts/,LICENSE-lucide.txt}, <section>/<page>.html and <section>/<page>.md, api/<module>.html, plus the marker file .summer-docs [VERIFIED: build run; docsite.go:24 const MarkerFile = ".summer-docs"].
  • Every internal link is absolute with the base, for example href="/docs/api/backpack.html", /docs/assets/site.css and src="/docs/assets/site.js". The URL rule is s.base + "/" + p, and nav/pager links use p.URL + ".html" [VERIFIED: internal/docsite/emit.go:23-27, 87-95, 187-195].
  • All ten handoff targets exist as .html: backend/admin-spa, database/models, services/routing, services/jobs, services/realtime, services/search, services/mail, console/introduction, setup/coming-from-wintercms and setup/installation [VERIFIED: test -f on the build output]. They do not exist without the extension. The built-in docsite.Handler does not resolve extension-less paths either (it serves files by path, a directory's index.html, otherwise 404.html with status 404) [VERIFIED: internal/docsite/serve.go:236-290].
  • --out handling: a non-empty out dir without .summer-docs is refused. With the marker it is emptied, then written (docsite.go:189-211). Never commit a placeholder inside the docs out dir. --out must not be inside --src or equal to the root (docsite.go:180-187).
  • Determinism: building from git archive HEAD | tar -x produced output byte-identical to the working tree (diff -r clean). The docs build can run against a tag export [VERIFIED: this session].
  • site.yaml parsing: the Site struct has the fields title, description, base_url, edit_url, source_url, llms_notes and sections, decoded with yaml.DisallowUnknownField() [VERIFIED: internal/docsite/load.go:21-31, 88-93]. An unset new key needs a struct field, or decode fails. The CLI --base-url overrides base_url (s.base = strings.TrimRight(cfg.BaseURL, "/"), then if opts.BaseURL != "" {...}) [VERIFIED: load.go:217-221].
  • Header template (whole file) [VERIFIED: internal/docsite/theme/templates/header.html:1-9]: menu button, <a class="wordmark" href="{{.HomeURL}}" ...>, <div class="header-spacer"></div>, search trigger, theme toggle. pageView carries HomeURL etc. and is filled by baseView (emit.go:62-95). The 404 page also uses baseView(nil) (emit.go:144). D-41 needs: Site.SiteURL string \yaml:"site_url"`→ apageView.SiteURLfield set inbaseView→{{if .SiteURL}}…{{end}}in header.html → a.site-linkrule intheme/assets/site.css. The theme already inlines icon-chevron-left` (11.1 UI-SPEC icon list).
  • Which docs page documents site.yaml: none fully. docs/console/utilities.md:31-46 documents docs:build / docs:serve flags in a table (--root, --src, --out, --base-url, --check). Add the site_url key (and the recommended --site-url flag) there, preferably with a short "site.yaml keys" paragraph.
  • Checker requirements for a new key: TestDocsTree (cmd/summer/docs_test.go:25-33) runs docsite.Check on the real tree. Command words are checked against the real command list, but flags are stripped (cobra stripFlags), so a new flag is not validated. TestParseSite (internal/docsite/load_test.go:11-50) has an unknown key case. Add a site_url positive case and a render assertion that the link is absent when unset (output unchanged) and present when set.
  • How summercms.io sets site_url: / without changing the framework's own docs/site.yaml: the docs are built from the framework tag's docs/site.yaml, which must not name the site, and setting / there would also make summer docs:serve link to itself. Recommend a --site-url flag on docs:build (and docs:serve) that overrides site_url, mirroring --base-url. The build script then passes --base-url /docs --site-url /. This is a small extension of D-41 (see Open Question 1).

PostgreSQL 15 (question 3)

Every pinned image (9 Go harnesses plus 1 shell gate). The value "postgres:16-alpine" was read at modules/lagoon/postgres_test.go:52-58 (postgres.Run(ctx, "postgres:16-alpine", postgres.WithDatabase("lagoon"), ...)). The rest were grep-located:

File:line Kind
modules/lagoon/postgres_test.go:53 TestMain harness
modules/lagoon/attach/lifecycle_test.go:273 harness
modules/cabana/query_test.go:275 harness
modules/cabana/auth_test.go:329 harness
modules/beachcomber/postgres_test.go:56 harness
modules/lighthouse/postgres_test.go:56 harness
modules/bouncer/phase07_coverage_test.go:67 harness
modules/conga/postgres_test.go:59 TestMain harness
docs/examples/blog/postgres_test.go:68 harness
scripts/check-phase8.sh:257 fonoteka phase-8 gate (shell). Out of scope.

The image is a hard-coded literal everywhere. There is no env var or parameter.

Docs stating the version:

  • README.md:15 "PostgreSQL 16 for any application that uses the data layer"
  • README.md:35 "Create a PostgreSQL 16 database:"
  • docs/setup/installation.md:14 "PostgreSQL 16 for any application that uses the data layer."

These three describe only the test image, not a requirement, so leave them or add a "also verified on 15" sentence:

  • docs/plugins/testing.md:34
  • modules/lagoon/README.md:201
  • modules/conga/README.md:207

How to run on 15 without permanent edits (done this session):

S=$(mktemp -d); git -C summercms.go archive HEAD | tar -x -C "$S"
cd "$S" && grep -rl 'postgres:16-alpine' --include=*.go . | xargs sed -i 's#postgres:16-alpine#postgres:15#g'
go test -count=1 -p 4 ./modules/lagoon/... ./modules/cabana/... ./modules/beachcomber/... \
  ./modules/lighthouse/... ./modules/bouncer/... ./modules/conga/... ./docs/examples/blog/...

The result was all ok: lagoon, lagoon/attach, cabana, beachcomber (+typesense), lighthouse (+centrifugo), bouncer, conga, docs/examples/blog (+console, controllers, models, updates), with EXIT=0. Verbose confirmation: 🐳 Creating container for image postgres:15 and Waiting for container ... image: postgres:15 [VERIFIED: this session; docker run --rm postgres:15 postgres --version → PostgreSQL 15.16 (Debian 15.16-1.pgdg13+1)]. Rome runs 15.19, a later patch of the same major.

  • No PG16-only SQL was found in non-test code. A grep for ANY_VALUE, SQL/JSON constructors, IS JSON, daticulocale, datlocale, SYSTEM_USER and pg_stat_io found nothing. The ICU database-locale check was removed by quick task 261001-ddh (037dc53). pl-x-icu per-query collation needs an ICU-enabled server, and Debian's postgresql-15 is ICU-enabled [ASSUMED]. The site never uses Polish ordering.
  • Docker 29.7.2 with a live daemon is available, and postgres:15 and postgres:15-alpine are already pulled.
  • Recommendation: plan 02 re-runs exactly this scripted, throwaway procedure, records the go test output in its SUMMARY, then edits the three doc lines. Prefer postgres:15 (Debian, like rome) over -alpine. An env-var override for the test image (for example SUMMER_TEST_POSTGRES_IMAGE) would make this repeatable, but it is a framework change outside D-25's wording. Offer it as optional (Open Question 3).

Nuxt 4 (question 4): spike-verified configuration

A minimal project with the fonoteka versions was installed (13 s, warm pnpm store) and generated (19 s) in scratch.

Output of nuxt generate (.output/public, plus a dist symlink to it in the project root):

index.html  200.html  404.html  _payload.json  robots.txt  sitemap.xml  og-image.png
_nuxt/<hash>.js  _nuxt/entry.<hash>.css  _nuxt/error-404.<hash>.css  _nuxt/error-500.<hash>.css
_nuxt/builds/latest.json  _nuxt/builds/meta/<buildId>.json      <- NOT content-hashed names
_fonts/<hash>.woff2 (4 files with the subset/style config below)
_i18n/<hash>/en/messages.json                                    <- fetched at hydration
__sitemap__/style.xsl

The HTML preloads /_payload.json?<buildId> and modulepreloads /_nuxt/*.js.

Spike results that drive the config:

  1. Prerender fails on /docs links. nuxt generate exit 1: [404] Page not found: /docs ... Linked from /, ERROR Exiting due to prerender errors. Fix (verified, exit 0): nitro: { prerender: { ignore: ['/docs'] } } and linkChecker: { excludeLinks: ['/docs', '/docs/**'] }.
  2. With i18n, the sitemap becomes an index. It produced sitemap_index.xml, __sitemap__/en-US.xml and a directory sitemap.xml/index.html meta-refresh, and robots pointed at sitemap_index.xml. Fix (verified): sitemap: { autoI18n: false } gives one flat sitemap.xml, and robots says Sitemap: https://summercms.io/sitemap.xml.
  3. Fonts: with only weights, @nuxt/fonts fetched 26 files (normal+italic × every subset). With styles: ['normal'], subsets: ['latin', 'latin-ext'] it fetched 4 files. The @font-face rules are inlined in entry.<hash>.css with url(../_fonts/...). No googleapis/gstatic reference appears in the HTML.
  4. Title duplication: page title "SummerCMS" + site.name "SummerCMS" rendered og:title "SummerCMS | SummerCMS". Set the home title so the template does not duplicate (for example a full title plus titleTemplate: '%s' for the index page). The handoff names no <title> text, so the copy is planner/user discretion; the live page uses "SummerCMS - Coming Soon".
  5. Generated head (good defaults): canonical https://summercms.io/, og:url, og:site_name, og:locale en_US, twitter:card summary_large_image, an absolute og:image from site.url, robots index, follow, max-image-preview:large, schema.org WebSite + WebPage JSON-LD and <html lang="en-US">.
  6. i18n v10 single locale: with strategy: 'prefix_except_default' and defaultLocale: 'en', en is served at /, and 11.3 adds pl at /pl/ without moving /. Messages are emitted to _i18n/<hash>/en/messages.json and fetched on hydration, so the Go handler must serve _i18n/ (Pitfall 7). Locale files live at i18n/locales/en.json (restructureDir default i18n/ + langDir: 'locales/', the same as fonoteka's i18n/locales/*.json). CONTEXT's "locales/en.json" means that file.
  7. ogImage: { enabled: false } plus a static public/og-image.png worked with no warnings. Fonoteka also ships static OG PNGs.
  8. The harmless warning [nuxt-seo-utils] treeShakeUseSeoMeta requires Unhead v3 appears. Ignore it.

Fonoteka's static-build i18n note (_i18n/**/*.json): that comment concerns its PWA precache manifest. On ssr: true builds, i18n v10 fetches /_i18n/<hash>/<locale>/messages.json at runtime, and the file must be reachable. Fonoteka sets experimental.prerenderMessages: true. The spike emitted the file without that flag. Setting it anyway makes emission a contract rather than an accident (recommended).

Package manager / runtime: pnpm (fonoteka has pnpm-lock.yaml lockfileVersion 9.0 and pnpm-workspace.yaml allowBuilds). Node 22.23.2 locally, and fonoteka .nvmrc is 22.22.0. Fonoteka also patches nuxtseo-shared@5.2.2 (patches/), but only to stop a devtools crash in nuxt dev. Set devtools: { enabled: false } and do not copy the patch unless pnpm dev crashes.

Design Handoff Inventory (question 5)

Sections in order: sticky header → hero #top → #why → #features (full-bleed band) → #winter → #start (full-bleed band) → footer.

Tokens → :root custom properties (D-35):

Token Value
--navy-900 #18223a
--navy-850 #1d2940
--navy-800 #1f2b42
--navy-700 #233148
--navy-650 #253350
--navy-600 #29344a
--yellow-500 #f5c55a
--yellow-400 #fbd77e
--yellow-200 #fbe3a6
--yellow-100 #fde9b4
--text #e9edf3
--text-2 #c5ccd6
--text-3 #a3adbd
--text-4 #8a94a3
  • Borders: white at 6, 7, 8, 10, 12 and 14%.
  • Radii: 6px, 16px and 999px.
  • Spacing scale: 4, 6, 8, 12, 16, 20, 24, 28, 32, 40, 48, 96 and 120px.
  • Container: max-width:1120px; padding:0 24px.
  • Section padding: 96px.
  • html{scroll-behavior:smooth; scroll-padding-top:72px}.
  • Selection: #f5c55a on #1d2940.
  • Body: font-family: Roboto, system-ui, sans-serif; -webkit-font-smoothing: antialiased.

Details found in SummerCMS Landing.dc.html that are not in README.md (match these):

  • Header nav row gap:4px, inside flex:1; min-width:0; overflow-x:auto; scrollbar-width:none.
  • Docs button gap:8px, with the arrow in its own <span>.
  • Header logo <img> is 30×30 with mask-image: radial-gradient(circle,#000 52%,transparent 70%) and transform: scale(1.35), logo row gap:10px. The mask was there to crop the screenshot placeholder. With the transparent original, match the visual size by screenshot comparison rather than copying the mask blindly.
  • Hero tagline letter-spacing: 0.01em. text-wrap: pretty on the description, H2s and card bodies.
  • Secondary CTA text #e9edf3, hover #fff, gap:10px. Primary CTA hover text stays #1d2940.
  • Feature tile hover text #fff.
  • Concept rows: grid-template-columns: minmax(0,1fr) minmax(0,1.3fr); gap:12px; line-height:1.5. Every row has a bottom border, including the last.
  • "See the full concept map →": display:inline-flex; margin-top:24px.
  • Get started:
    • Chips row margin-top:24px; gap:8px.
    • Note margin-top:20px, 16px/1.65.
    • Buttons row margin-top:28px.
    • Installation guide padding 13px 24px. Source outline padding 12px 22px, border rgba(255,255,255,0.14), hover bg rgba(255,255,255,0.08) with text #fff.
  • Terminal:
    • Title bar padding:12px 18px, bottom border rgba(255,255,255,0.07).
    • Copy button font 500 12px Roboto.
    • Body overflow-x:auto; display:flex; flex-direction:column. Each line is a white-space:pre block. Each group's comment line after the first has margin-top:16px.
  • Footer: row gap:24px; flex-wrap:wrap, then a flex:1 spacer, then the link group gap:20px.
  • Copy uses typographic apostrophes: "each plugin’s YAML" and "the caller’s transaction".
  • The prototype loads Roboto 900 as well. D-34 says 300/400/500/700. 900 is unused, so follow D-34.

Breakpoints/interactions:

  • Section links are hidden at ≤720px. Use CSS @media (max-width: 720px), not the prototype's JS wide state (Pitfall 4).
  • Scroll-spy: the active section is the last of why, features, winter, start whose getBoundingClientRect().top < 140. Before #why, nothing is active. Listen to scroll with {passive:true} and also compute on mount. Gate it on appConfig.scrollSpy (D-45).
  • Copy: navigator.clipboard.writeText(lines.join('\n')), label "Copied" for 1500ms, then "Copy". Gate the rays on appConfig.showRays.
  • Transitions: optional 150ms ease on background/color. Reduced motion: @media (prefers-reduced-motion: reduce){ html{scroll-behavior:auto} } (discretion, recommended).

Terminal command list after D-39 (single source; six commands, three groups):

# get the framework
$ git clone https://git.golem15.com/golem15/summercms
$ cd summercms

# install the summer CLI
$ go install ./cmd/summer

# build and run the example app
$ cd examples/hello
$ summer build
$ ./bin/hello greeter:hello

Copy payload: git clone https://git.golem15.com/golem15/summercms\ncd summercms\ngo install ./cmd/summer\ncd examples/hello\nsummer build\n./bin/hello greeter:hello.

Every link on the page (verified against the real docs build this session):

Where href (handoff) Resolves to Status
Header logo, footer "Back to top ↑" #top hero section id in-page
Header section links #why #features #winter #start section ids in-page
Header "Docs →", hero "Read the docs", footer "Docs" /docs /docs/ → index.html ✓ (via redirect)
Hero "Alpha 0.1 is out" #start section id in-page
Feature tiles (8) /docs/backend/admin-spa, /docs/database/models, /docs/services/routing, /docs/services/jobs, /docs/services/realtime, /docs/services/search, /docs/services/mail, /docs/console/introduction <same>.html ✓ files exist as .html only
WinterCMS "See the full concept map →" /docs/setup/coming-from-wintercms .html ✓
Get started "Installation guide" /docs/setup/installation .html ✓
Get started "Source", footer "Source" https://git.golem15.com/golem15/summercms 404 anonymously today (Gitea, private) ✗ until made public (D-38 launch item)
Footer credit (D-12) https://golem15.com external check at cutover

Recommendation:

  • Write page hrefs in the canonical .html form (/docs/backend/admin-spa.html). The handoff itself says "Doc URLs were derived from the docs folder structure. Confirm them against the built docs site." This keeps one URL per page and makes the link check an exact file check.
  • Make the docs handler also 301 extension-less paths to .html when that file exists, so typed or shared pretty URLs work.

Identifiers named on the page exist: party.Plugin, pact.HasMigrations, pact.HasRoutes, festival.Bus and bonfire.Command all resolved with go doc [VERIFIED: this session].

D-40: does ./bin/hello greeter:hello need a database? No. This was run verbatim in a fresh git clone of the local repo, with GOBIN=<tmp>/gobin and PATH=<tmp>/gobin:$PATH set and no SUMMER_* env:

  1. go install ./cmd/summer
  2. cd examples/hello
  3. summer build printed built hello in 2.185s.
  4. ./bin/hello greeter:hello </dev/null printed the table golem15.greeter active and name=hello-app posts_per_page=10 debug=false extra=hello-from-optional events=ok collected=greeter handled=true, EXIT=0.
  5. The tree was clean afterwards (no main.go drift), so the README "Known issues" note about a stale main.go is itself stale.

greeter:hello calls out.Confirm("show greeting?", true). In non-interactive mode Confirm returns the default (if !c.interactive { return def, nil }) [VERIFIED: modules/bonfire/prompts.go:29-32], so the check needs no stdin. Requirements for the check:

  • (a) a temp GOBIN placed first on PATH, because a stale summer may already be on the user's PATH;
  • (b) a clone source override until the repo is public, because git ls-remote https://git.golem15.com/golem15/summercms fails with "could not read Username" (verified).

Deploy (question 6)

Live site observations (fetched this session):

  • http://summercms.io/ → 301 https://summercms.io/ (nginx).
  • https://summercms.io/ → HTTP/2 200 text/html, 9816 bytes, last-modified: Wed, 04 Feb 2026. Title "SummerCMS - Coming Soon", gzip on.
  • https://summercms.io/logo.png → 200, image/png, 512916 bytes, PNG 746×744 RGBA interlaced. This matches D-36. http://summercms.io/logo.png 301s to HTTPS.
  • robots.txt and favicon.ico return 404. The page uses logo.png as its favicon and OG image.
  • The Let's Encrypt cert (issuer YE1) has SAN summercms.io, www.summercms.io.
  • http://www.summercms.io/ 301s to https://summercms.io/, but https://www.summercms.io/ fails the TLS handshake ("unrecognized name"): there is no 443 server block for www. DEPLOY.md can fix this with a 443 www→apex redirect block.
  • The current site is a static docroot (one index.html plus logo.png). The rollback is the saved server block plus that docroot. A copy of the live HTML was captured in this session's scratch dir.

DEPLOY.md content requirements (discretion values, marked [ASSUMED] until confirmed on rome):

  1. Launch checklist:
    • Create the remotes on git.golem15.com for the three repos (D-43).
    • Make golem15/summercms public (D-38).
    • Confirm the v0.1.0 tag exists (D-42).
    • Run the external link check anonymously.
  2. One-time server setup:
    • adduser --system --group --home /srv/summercms-io --shell /usr/sbin/nologin summercms [ASSUMED path/user].
    • sudo -u postgres createuser --pwprompt summercms.
    • sudo -u postgres createdb -O summercms -E UTF8 summercms_io (D-27).
    • Create /srv/summercms-io/{bin,config,storage}.
    • Server-only /srv/summercms-io/.env (owner summercms, mode 0600) with SUMMER_DATABASE__DSN=postgres://summercms:<pw>@127.0.0.1:5432/summercms_io?sslmode=disable and SUMMER_APP__KEY=<from ./bin/summercms-io key:generate>. (Peer auth over the unix socket is an alternative that needs no password. D-27 says password.)
  3. Build (local): scripts/build.sh (see Code Examples) produces bin/summercms-io (linux/amd64, CGO_ENABLED=0).
  4. Upload:
    • rsync -av --chmod=F644,D755 config/ rome:/srv/summercms-io/config/. Exclude any server-only files; .env lives outside config/.
    • rsync -av bin/summercms-io rome:/srv/summercms-io/bin/summercms-io.new.
  5. Release (every deploy):
    • cd /srv/summercms-io && sudo -u summercms ./bin/summercms-io.new migrate. The cwd matters because config is read relative to it.
    • cp -p bin/summercms-io bin/summercms-io.prev (if present).
    • mv bin/summercms-io.new bin/summercms-io.
    • supervisorctl restart summercms-io.
    • curl -sI http://127.0.0.1:8095/.
  6. Supervisor program /etc/supervisor/conf.d/summercms-io.conf:
    • command=/srv/summercms-io/bin/summercms-io serve --addr 127.0.0.1:8095 [ASSUMED port; check with ss -ltnp on rome]
    • directory=/srv/summercms-io
    • user=summercms
    • environment=SUMMER_ENV="production"
    • autostart=true, autorestart=true, startsecs=3
    • stopsignal=TERM, stopwaitsecs=15 (serve shuts down within 10 s on SIGTERM, surf/serve.go:38, 70-77)
    • redirect_stderr=true, stdout_logfile=/var/log/supervisor/summercms-io.log with size/backups
    • Then supervisorctl reread && supervisorctl update.
  7. nginx server blocks (replace the existing summercms.io block in place, D-31):
    • port 80 → 301 to https://summercms.io$request_uri
    • 443 www → 301 to apex
    • 443 apex with the certbot paths ssl_certificate /etc/letsencrypt/live/summercms.io/fullchain.pem, ssl_certificate_key .../privkey.pem, include /etc/letsencrypt/options-ssl-nginx.conf, ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem. Copy the exact lineage name from the existing block; it may not be summercms.io [ASSUMED].
    • gzip on; gzip_vary on; gzip_proxied any; gzip_types text/css text/plain text/xml application/xml application/json text/javascript application/javascript image/svg+xml text/markdown; (woff2 and png are already compressed).
    • location ^~ /backend { return 404; } (D-29).
    • location / { limit_except GET HEAD { deny all; } proxy_pass http://127.0.0.1:8095; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }.
    • Use listen 443 ssl http2;. That is Debian 12's nginx 1.22 syntax [ASSUMED version]. It is deprecated but still valid on ≥1.25, and local nginx 1.30.4 can nginx -t it.
    • Then nginx -t && systemctl reload nginx.
  8. Rollback:
    • App: mv bin/summercms-io.prev bin/summercms-io && supervisorctl restart summercms-io. Migrations are not reversed; 11.2 has no plugin migrations.
    • Cutover: restore the saved "Under construction" server block and its docroot, then nginx -t && systemctl reload nginx.
  9. Verification after cutover: curl checks for status, Cache-Control, no X-Robots-Tag: noindex, /docs/ 200, /backend 404, POST / 403 (nginx), and HTTPS www→apex. Then run the link check against https://summercms.io.

Architecture Patterns

System Architecture Diagram

 BUILD (local, scripts/build.sh in sm-summercmsio-app)
 ┌──────────────────────────┐   pnpm install --frozen-lockfile + nuxt generate
 │ vue-summercmsio-app (sub)  │──────────────► .output/public/ ──rsync -a --delete──┐
 └──────────────────────────┘                                                     ▼
 ┌──────────────────────────┐   git archive v0.1.0 → tmp; go run ./cmd/summer     plugins/golem15/summercms/public/site/
 │ ../summercms.go @ v0.1.0 │   docs:build --base-url /docs --site-url / ────────► plugins/golem15/summercms/public/docs/
 └──────────────────────────┘                                                     │
                       link check + terminal-list drift test (go test, REQUIRE_BUILD=1)
                                                                                  ▼
            summer build (main.go/plugins.gen.go) + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build
                                                                                  ▼
                                                                    bin/summercms-io (one file)
 RUNTIME (rome)
 Browser ──HTTPS──► nginx :443 ──┬─ /backend* ─► 404
                    (TLS, gzip,  ├─ non-GET/HEAD ─► 403
                     www→apex)   └─ everything else ─proxy─► 127.0.0.1:8095  summercms-io serve
                                                              │ boot: config/ + .env → Postgres (summercms_io)
                                                              ▼
                                         surf ServeMux (GroupRaw, plugin golem15.summercms)
                                           ├─ GET /docs → 301 /docs/
                                           ├─ GET /docs/{path...} ─► docs fs: file │ x→x.html (301) │ dir/index.html │ 404.html(404)
                                           └─ GET / (catch-all) ──► site fs: file │ dir/index.html │ 404.html(404)
                                               headers: Content-Type (own table), Cache-Control by path, ETag
summercms/                               # meta repo dir (siblings)
├── summercms.go/                        # framework (D-25, D-41, D-42 changes only)
└── sm-summercmsio-app/                    # root app, module git.golem15.com/golem15/sm-summercmsio-app
    ├── go.mod  go.work  summer.yaml     # replace summercms => ../summercms.go; use . ./plugins/golem15/summercms
    ├── main.go  plugins.gen.go          # generated by summer build (committed, like fonoteka)
    ├── config/{app,http,storage,database,queue}.yaml   # no secrets; work_in_serve: false
    ├── scripts/build.sh                 # D-05/D-28 build; flags --release (tag required) / dev
    ├── deploy/{nginx-summercms.io.conf, supervisor-summercms-io.conf, rollback/}   # referenced by DEPLOY.md
    ├── DEPLOY.md  README.md  .gitignore (/bin/ /tmp/ go.work.sum .env)
    ├── terminal_check_test.go           # D-40 (gated by SUMMERCMS_TERMINAL_CHECK=1)
    ├── vue-summercmsio-app/               # submodule (Nuxt 4)
    │   ├── nuxt.config.ts  app/app.config.ts  app/app.vue  app/pages/index.vue
    │   ├── app/components/{SiteHeader,HeroSection,WhySection,FeaturesSection,WinterSection,StartSection,TerminalCard,SiteFooter}.vue
    │   ├── app/assets/css/{tokens.css,base.css}
    │   ├── app/data/terminal.json       # SINGLE SOURCE of the six commands (+ comment i18n keys)
    │   ├── app/utils/{scrollSpy.ts,terminal.ts}      # pure functions, node --test
    │   ├── i18n/locales/en.json         # all copy (D-33)
    │   ├── public/{og-image.png,favicon.ico,favicon-32x32.png,apple-touch-icon.png,sun-*.png|webp}
    │   ├── scripts/derive-images.sh     # ImageMagick one-off from logo.png
    │   └── tests/*.test.ts              # node --test
    └── plugins/golem15/summercms/       # submodule sm-summercmsio-plugin, module git.golem15.com/golem15/sm-summercmsio-plugin
        ├── go.mod (replace summercms => ../../../../summercms.go)
        ├── plugin.go   (ID golem15.summercms, Routes, embed all:public)
        ├── static.go   (handler: resolve, MIME, cache, ETag, 404)
        ├── public/README.md (committed placeholder; site/ and docs/ gitignored)
        └── *_test.go

Pattern 1: Nuxt config (spike-verified core)

// Source: spike in this session (nuxt 4.4.8, @nuxtjs/i18n 10.4.0, @nuxtjs/seo 5.2.1, @nuxt/fonts 0.14.0)
export default defineNuxtConfig({
  compatibilityDate: '2025-07-15',
  devtools: { enabled: false },
  modules: ['@nuxt/fonts', '@nuxtjs/i18n', '@nuxtjs/seo'],
  css: ['~/assets/css/tokens.css', '~/assets/css/base.css'],
  fonts: {
    families: [
      { name: 'Roboto', weights: [300, 400, 500, 700], styles: ['normal'], subsets: ['latin', 'latin-ext'], global: true },
      { name: 'Roboto Mono', weights: [400, 500], styles: ['normal'], subsets: ['latin', 'latin-ext'], global: true },
    ],
  },
  i18n: {
    strategy: 'prefix_except_default',
    defaultLocale: 'en',
    locales: [{ code: 'en', language: 'en-US', file: 'en.json', name: 'English' }],
    langDir: 'locales/',
    baseUrl: 'https://summercms.io',
    experimental: { prerenderMessages: true },   // fonoteka precedent; makes _i18n emission explicit
  },
  site: { url: 'https://summercms.io', name: 'SummerCMS', description: 'A new dawn in content management', defaultLocale: 'en' },
  ogImage: { enabled: false },                     // static public/og-image.png (D-37: nothing at runtime)
  sitemap: { autoI18n: false },                    // one flat sitemap.xml (spike)
  linkChecker: { excludeLinks: ['/docs', '/docs/**'] },
  nitro: { prerender: { ignore: ['/docs'] } },     // REQUIRED: else generate exits 1 on /docs links
})
// app/app.config.ts (D-45): export default defineAppConfig({ landing: { showRays: true, scrollSpy: true } })

Pattern 2: Site plugin with an embedded static handler (stdlib only)

// Source: patterns from modules/boardwalk/boardwalk.go (fs.FS + ServeContent + cache by prefix) and
// modules/surf/router.go (GroupRaw), adapted for a public, indexable site. [ASSUMED code; APIs VERIFIED above]
package summercms

//go:embed all:public          // all: is required: _nuxt/, _fonts/, _i18n/, _payload.json start with '_'
var publicFS embed.FS

type Plugin struct{ site, docs http.Handler }

func (p *Plugin) ID() string         { return "golem15.summercms" }
func (p *Plugin) Requires() []string { return nil }
func (p *Plugin) Register(*backpack.App) error { return nil }
func (p *Plugin) Boot(*backpack.App) error     { return nil }

func (p *Plugin) Routes(r pact.Router) error {
	site, docs, err := handlers(publicFS) // fs.Sub "public/site", "public/docs"; fail closed if index.html missing
	if err != nil {
		return err // e.g. "summercms: public/site/index.html missing; run scripts/build.sh"
	}
	r.GroupRaw("", nil, func(g pact.Router) {
		g.Get("/docs", redirect("/docs/"))      // 301 instead of ServeMux's 307
		g.Get("/docs/{path...}", docs.ServeHTTP) // prefix "/docs/" stripped inside
		g.Get("/", site.ServeHTTP)              // catch-all, lowest precedence
	})
	return nil
}

func init() { party.Register(&Plugin{}) }

Handler rules:

  • Clean the path with path.Clean("/"+rel). Refuse any segment starting with ., which hides .summer-docs.
  • Resolution order: exact regular file, then <p>/index.html, then (docs only) <p>.html → 301 to /docs/<p>.html, built from the cleaned path only. Otherwise serve 404.html with status 404.
  • Set Content-Type from an own table before mime.TypeByExtension.
  • Set Cache-Control per the table below.
  • Precompute a strong ETag (sha256 prefix) per file at construction, so no-cache revalidates to 304 through http.ServeContent.
  • No X-Robots-Tag and no CSP. X-Content-Type-Options: nosniff and Referrer-Policy: strict-origin-when-cross-origin are fine.

Cache-Control by path (D-07, adjusted to what is actually hashed):

Path Hashed? Cache-Control
/_nuxt/* except /_nuxt/builds/* yes (B_P4Zhpw.js, entry.UH8Ffg1R.css) public, max-age=31536000, immutable
/_fonts/* yes (content-hash names) public, max-age=31536000, immutable
/_nuxt/builds/latest.json, /_nuxt/builds/meta/* no (polled for new deploys) no-cache
*.html, /, /_payload.json, /_i18n/**, robots.txt, sitemap.xml, og-image.png, favicons, sun images no no-cache (+ETag)
/docs/** including /docs/assets/* no (/docs/assets/site.css is a fixed name) no-cache (+ETag)

MIME table the plugin must own. Go 1.27's builtin table [VERIFIED: $(go env GOROOT)/src/mime/type.go builtinTypesLower] has .css .html .js .json .mjs .png .svg .txt .ico .xml .xsl .webp but not .woff2, .woff, .md or .webmanifest. Without /etc/mime.types on rome, fonts and /docs/*.md would be served as application/octet-stream. Own entries:

  • .woff2 → font/woff2
  • .woff → font/woff
  • .md → text/markdown; charset=utf-8
  • .webmanifest → application/manifest+json
  • .json → application/json
  • .xml/.xsl → application/xml; charset=utf-8 (or keep the builtin text/xml)
  • .txt → text/plain; charset=utf-8
  • .html → text/html; charset=utf-8
  • .css → text/css; charset=utf-8
  • .js/.mjs → text/javascript; charset=utf-8

Pattern 3: Single-source terminal commands (D-40)

vue-summercmsio-app/app/data/terminal.json (imported by TerminalCard.vue; comments are i18n keys):

{ "groups": [
  { "comment": "terminal.getFramework", "commands": ["git clone https://git.golem15.com/golem15/summercms", "cd summercms"] },
  { "comment": "terminal.installCli",   "commands": ["go install ./cmd/summer"] },
  { "comment": "terminal.buildExample", "commands": ["cd examples/hello", "summer build", "./bin/hello greeter:hello"] }
] }
  • Check (Go test in sm-summercmsio-app, TestTerminalCommands):
    • Skip unless SUMMERCMS_TERMINAL_CHECK=1.
    • Read the JSON and make a temp dir and a temp GOBIN, with PATH=$GOBIN:$PATH, GOWORK unset and no SUMMER_* env.
    • Run bash -euo pipefail -c "<commands joined by \n>" in the temp dir with stdin /dev/null.
    • If SUMMERCMS_CLONE_URL is set, substitute only the clone URL (pre-launch, for example the local ../summercms.go). Unset means verbatim (cutover).
    • Assert exit 0 and handled=true in the output.
  • Drift guard (plugin test after build): assert the embedded public/site/index.html contains each command string and each of the three comment texts. The page and the check then cannot diverge.

Anti-Patterns to Avoid

  • //go:embed public without all:. Silently drops _nuxt/, _fonts/, _i18n/ and _payload.json, so the page renders unstyled and hydration fails [CITED: pkg.go.dev/embed].
  • Embedding the dist symlink. nuxt generate creates dist -> .output/public, and embed refuses symlinks [CITED: pkg.go.dev/embed]. Copy with rsync -a --delete .output/public/ <plugin>/public/site/.
  • Reusing boardwalk.Handler or boardwalk.SetSecurityHeaders. These bring noindex, CSP and the SPA fallback (D-07).
  • Implementing pact.HasAdminControllers (as the make:plugin stub does). It would activate cabana, require admin.jwt.secret and mount /backend.
  • Committing a placeholder file inside public/docs/. docs:build refuses to clean a dir without the .summer-docs marker.
  • Copying the prototype's JS wide state. SSR cannot know the width, which causes a hydration mismatch and layout shift. Use a CSS media query.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Range/HEAD/If-None-Match handling Manual header logic http.ServeContent with a preset ETag Handles HEAD, 304, ranges and Content-Length correctly
Path normalisation / traversal String hacks path.Clean + fs.Sub over embed.FS + dot-segment refusal embed.FS cannot escape its root, and Clean defeats ..
Route precedence Own prefix router surf GroupRaw → stdlib ServeMux Verified conflict-free alongside cabana
Sitemap/robots/schema.org/OG tags Hand-written XML/JSON-LD @nuxtjs/seo (spike output above) Already decided (D-37)
Font self-hosting Manually downloaded woff2 + @font-face @nuxt/fonts Hashed names, subsets, fallback metrics
Docs site Anything summer docs:build from the tag Deterministic, checker-guarded
Image derivation A Go/Node image pipeline One-off magick script, outputs committed No build-time dependency; sharp is SUS

Key insight: the only genuinely new code is a ~150-line static handler plus wiring. Everything else is configuration of tools already verified in this repo or in fonoteka.

Common Pitfalls

What goes wrong: exit 1, [404] Page not found: /docs/setup/installation ... Linked from /. Why: the nitro prerender crawler follows every <a href> and /docs is not a Nuxt route. Avoid: nitro.prerender.ignore: ['/docs'] plus linkChecker.excludeLinks. Warning sign: "Errors prerendering" in the generate log. [VERIFIED: spike]

Pitfall 2: Sitemap index plus a sitemap.xml/ directory under i18n

What goes wrong: sitemap.xml becomes a directory holding a meta-refresh HTML, which the Go handler would serve as HTML. Avoid: sitemap: { autoI18n: false }. [VERIFIED: spike]

Pitfall 3: position: sticky header inside an overflow-x: hidden wrapper

What goes wrong: the prototype wraps everything in <div style="min-height:100vh;overflow-x:hidden">. A non-visible overflow makes that div the sticky containing scroll box, and since the window scrolls instead, the header stops sticking. Avoid: use overflow-x: clip on the wrapper (or none, relying on the hero's overflow:hidden for the rays). Warning sign: the header scrolls away in the browser UAT. [ASSUMED: CSS spec behaviour; verify in browser]

Pitfall 4: Hydration mismatch from width-dependent rendering

The prototype renders nav links only when wide. Render them always and hide them with @media (max-width: 720px) { .nav-links { display: none } }. Scroll-spy state starts as '' on SSR and is computed in onMounted.

Pitfall 5: The Clipboard API needs a secure context

navigator.clipboard is undefined on plain-HTTP non-localhost origins (for example testing via a LAN IP). Guard with navigator.clipboard?.writeText(...) and fall back to a hidden-textarea document.execCommand('copy'), or skip it. HTTPS production and localhost are fine. [ASSUMED]

Pitfall 6: vue-i18n message syntax in copy

@, |, { and } are special in vue-i18n messages (linked messages, plurals, interpolation). None appear in the current copy, but a future @ (an email) needs {'@'}. Inline <code> inside the Why card body (fields.yaml, columns.yaml) should use <i18n-t keypath> with slots, or split keys, not v-html. [ASSUMED]

Pitfall 7: go:embed drops _-prefixed files

Use //go:embed all:public. Then refuse dot-segments in the handler, because all: also embeds .summer-docs. [CITED: pkg.go.dev/embed]

Pitfall 8: Immutable caching of non-hashed files

/docs/assets/site.css and /_nuxt/builds/latest.json have fixed names. Marking them immutable pins a stale CSS or a stale build manifest in browsers for a year. Use the cache table above. [VERIFIED: docs output and spike output]

Pitfall 9: Extension-less docs URLs 404

Docs pages are x.html files and the docs index is index.html. Resolve or redirect x → x.html, and use .html hrefs on the page. [VERIFIED: docs build]

Pitfall 10: Missing MIME types on a minimal server

.woff2 and .md are not in Go's builtin table, so ship an explicit table. [VERIFIED: GOROOT mime/type.go]

Pitfall 11: Open redirect in the .html redirect

Build the Location only from "/docs/" + cleanedRel + ".html", never from the raw r.URL. ServeMux already redirects //host paths to cleaned ones, but do not rely on it.

Pitfall 12: serve boot requirements

Missing http.body_limits (numeric, YAML only, because the env overlay gives strings), app.key (32-byte base64), storage.uploads.bucket_url, database.dsn, or a running worker LISTEN pool all fail boot. Ship:

  • config/http.yaml with body_limits: {default_bytes: 1048576, upload_bytes: 1048576}
  • config/storage.yaml with uploads.bucket_url: "file://./storage/app/uploads" (or mem://)
  • config/queue.yaml with work_in_serve: false
  • config/app.yaml with key: "" and the secrets in .env

[VERIFIED: code reads above]

Pitfall 13: Tag ordering (D-42 is one-way)

The D-25 doc edits and the D-41 code, docs and smoke tests must be committed and green before v0.1.0 is created, or the embedded docs (built from the tag) will lack the PG15 wording and the header link. The tag checkpoint therefore comes after the framework tasks in plan 02. Any plan-03 framework tests land after the tag, which is acceptable because tests do not change v0.1.0 behaviour.

Pitfall 14: The README quick-start is stale for http.body_limits only

It also claims main.go drifts, which was not reproduced. Irrelevant to the page copy, which is final per D-40.

Code Examples

Build script outline (sm-summercmsio-app/scripts/build.sh)

#!/usr/bin/env bash
# Source: composed from verified steps in this session. [ASSUMED script; each command VERIFIED individually]
set -euo pipefail
APP="$(cd "$(dirname "$0")/.." && pwd)"; FW="$APP/../summercms.go"; PLUG="$APP/plugins/golem15/summercms"
TAG="$(cd "$APP" && go list -m -f '{{.Version}}' git.golem15.com/golem15/summercms)"   # v0.1.0 from require
MODE="${1:-release}"   # release: docs from $TAG export; dev: docs from working tree (HEAD export)
# 1) Nuxt
(cd "$APP/vue-summercmsio-app" && pnpm install --frozen-lockfile && pnpm generate)
rsync -a --delete "$APP/vue-summercmsio-app/.output/public/" "$PLUG/public/site/"
# 2) Docs from the tag (D-42)
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
REF="$TAG"; [ "$MODE" = dev ] && REF=HEAD
git -C "$FW" archive "$REF" | tar -x -C "$TMP"
(cd "$TMP" && go run ./cmd/summer docs:build --base-url /docs --site-url / --out "$PLUG/public/docs")
# 3) Checks against the built tree
(cd "$PLUG" && SUMMERCMS_REQUIRE_BUILD=1 go test ./...)
# 4) Binary
(cd "$APP" && summer build && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o bin/summercms-io .)

In release mode, to make "code agrees with tag" true for the binary as well, either assert git -C "$FW" describe --exact-match --tags HEAD equals $TAG and that the tree is clean, or build with a temporary GOWORK file that adds replace git.golem15.com/golem15/summercms => $TMP ("replace directives in go.work files override any replaces of the same module or module version in workspace modules" [CITED: go.dev/ref/mod#go-work-file-replace]). Recommend the temporary go.work. It needs no checkout switching, but the summer build step must then also run with GOWORK=$TMP/go.work. Note that summer build itself sets GOWORK to the nearest go.work (scaffold.go:519-525), so in release mode call go build directly after summer build has generated the sources.

D-41 header change

<!-- internal/docsite/theme/templates/header.html (after the wordmark) -->
{{- if .SiteURL}}<a class="site-link" href="{{.SiteURL}}">{{template "icon-chevron-left"}}<span>{{.SiteLabel}}</span></a>{{end}}
  • SiteLabel is derived (see Open Question 1).
  • html/template escapes the href attribute.
  • Validate site_url in ParseSite: it must be absolute http(s):// or root-relative /.... Reject javascript:.

State of the Art

Old Approach Current Approach When Changed Impact
nuxt-og-image runtime rendering Static OG PNG or zero-runtime prerender — No runtime rendering in Go (D-37)
i18n sitemap auto-split sitemap.autoI18n: false for single-locale sites @nuxtjs/sitemap 8.x One flat sitemap
Go ServeMux trailing-slash redirect 301 Observed 307 in Go 1.27 for /docs → /docs/ — Register an explicit 301 if a permanent redirect is wanted
TypeScript 5.x TS 7.0.2 (Go-native) published today 2026-10-01 Stay on ~5.9 for Nuxt tooling

Assumptions Log

# Claim Section Risk if Wrong
A1 Rome paths /srv/summercms-io, user summercms, port 127.0.0.1:8095 Deploy Port clash. Check ss -ltnp on rome before writing configs.
A2 The certbot lineage is /etc/letsencrypt/live/summercms.io/ with options-ssl-nginx.conf and ssl-dhparams.pem Deploy nginx -t fails. Copy the lines from the existing block.
A3 Rome's nginx is Debian 12's 1.22 (listen 443 ssl http2 syntax) Deploy Only a deprecation warning on ≥1.25
A4 Debian postgresql-15 is ICU-enabled PG15 Only lagoon Polish OrderBy is affected; the site does not use it
A5 The sticky header breaks under overflow-x:hidden Pitfall 3 Low. Verify in browser and use clip.
A6 Clipboard secure-context behaviour; vue-i18n special chars Pitfalls 5–6 Low
A7 TS 7 incompatibility with Nuxt typecheck Stack Low (pinned to 5.9)
A8 The static handler code skeleton (Pattern 2) Patterns The executor writes the real code; the APIs it uses are verified

Open Questions

  1. D-41 link label and how summercms.io sets site_url.
    • What we know: D-41 names one key, site_url, with summercms.io setting /. The docs are built from the framework tag's own docs/site.yaml, which must stay neutral. With a root-relative / there is no host to derive the "summercms.io" label from.
    • Recommendation: add the site_url key and a --site-url flag on docs:build/docs:serve (mirrors --base-url). Derive the label from the URL host when absolute, else use a fixed "Home" text. Alternatively add an optional site_label key / --site-label flag so summercms.io shows "← summercms.io". Ask the user at the plan checkpoint, since both extend D-41's literal wording.
  2. Landing hrefs .html vs pretty. Recommend .html hrefs plus a handler 301 for pretty URLs. This touches only hrefs, not copy, and the handoff asked for confirmation against the built docs. The user may prefer the pretty form, which still works via the redirect.
  3. A repeatable PG15 test switch. The one-off export plus sed procedure is enough for D-25. A SUMMER_TEST_POSTGRES_IMAGE env override is a nicer but extra framework change. Default: do not add it.
  4. <title>, meta description and OG image layout. The handoff gives no <title>. Proposal: title "SummerCMS: A new dawn in content management", description from the hero paragraph, OG 1200×630 on navy #233148 with the sun, wordmark and tagline (an ImageMagick script, Roboto from /usr/share/fonts/TTF). Planner discretion within the brand.
  5. Sun sizing in the badge. In the placeholder the sun fills about 75% of the 180px badge on navy. With the transparent original, render it at about 136px centred in the badge (2x asset 360px, webp 28 KB / png 113 KB measured). Confirm visually against the handoff screenshot.

Environment Availability

Dependency Required By Available Version Fallback
Go all Go builds/tests ✓ go1.27.0 linux/amd64 —
Node nuxt generate, node --test ✓ v22.23.2 —
pnpm Nuxt deps ✓ 11.3.0 npm (not recommended; lockfile parity with fonoteka)
Docker daemon D-25 PG15 run, local boot smoke ✓ 29.7.2 —
postgres:15 / postgres:15-alpine images D-25 ✓ (pulled) 15.16 docker pull postgres:15
ImageMagick (magick, WEBP/ICO) image derivation ✓ 7.1.2-30 —
Roboto TTF (for the OG render) OG image ✓ /usr/share/fonts/TTF/Roboto-*.ttf — —
rsync build copy + upload ✓ 3.5.0 cp -a locally
nginx (local, for nginx -t of the DEPLOY config) config validation ✓ 1.30.4 —
supervisor (local) config sanity ✓ 4.3.0 —
psql client local smoke ✓ 18.6 docker exec psql
Anonymous access to git.golem15.com/golem15/summercms D-38/D-40 verbatim clone, external link check ✗ (404 / auth prompt) Gitea SUMMERCMS_CLONE_URL override until the user makes it public
rome (nginx, supervisor, certbot, PG 15.19) deploy not reachable from here — User executes DEPLOY.md, a manual checkpoint

Missing dependencies with no fallback: none for build and test. Rome access is a human step. Missing with fallback: public repo access, via the clone override until cutover.

Validation Architecture

Test Framework

Property Value
Framework Go testing (stdlib, net/http/httptest, testing/fstest), plus node:test (built-in, runs .ts natively on Node 22.23)
Config file none. Go tests per repo. vue-summercmsio-app/package.json script "test": "node --test tests/*.test.ts"
Quick run command plugin: go test ./... (in plugins/golem15/summercms); framework: go test ./internal/docsite/ ./cmd/summer -run 'TestParseSite|TestDocsTree|TestBuildSiteMarkers'
Full suite command framework: go vet ./... && go test ./...; app: scripts/build.sh dev (runs the plugin tests with SUMMERCMS_REQUIRE_BUILD=1) then go vet ./... && go test ./...; site: pnpm generate && pnpm test

Phase Requirements → Test Map

Req Behavior Test Type Automated Command File Exists?
SC1 generate succeeds; sections/ids top why features winter start present; copy strings from en.json; no fonts.googleapis/gstatic; /_fonts/*.woff2 present; robots.txt + flat sitemap.xml; og:image absolute; six commands in HTML output assertion pnpm generate && node --test tests/output.test.ts (reads .output/public/index.html) ❌ Wave 0
SC1 scroll-spy picks the last section with top < 140, '' above #why; copy payload = six lines joined by \n unit (pure TS) node --test tests/scrollSpy.test.ts tests/terminal.test.ts ❌ Wave 0
SC1 visual fidelity at 1280/721/720/375px; sticky header; Copy → "Copied" 1.5s; nav hidden ≤720 manual UAT (browser) — (manual-only: pixel/interaction fidelity) n/a
SC2 / 200 text/html no-cache no X-Robots-Tag; /_nuxt/x.js immutable; /_nuxt/builds/latest.json no-cache; /_fonts/*.woff2 font/woff2 immutable; /docs 301; /docs/ 200; /docs/assets/site.css no-cache; /docs/x.md text/markdown; /missing 404 with 404.html body; /docs/missing 404 docs page; dot-path 404; ETag → 304; HEAD ok; .. traversal contained unit (handler with fstest.MapFS) go test ./... -run TestStatic (plugin) ❌ Wave 0
SC2 plugin routes assemble alongside a fake admin-controller plugin without conflict; no admin routes when alone unit go test ./... -run TestRoutesAssemble (plugin, surf.Assemble) ❌ Wave 0
SC2 binary boots on PG15 and serves both trees integration smoke scripts/smoke.sh (docker postgres:15, migrate, serve, curl assertions) ❌ Wave 0
SC3 every href in the built index.html resolves: internal via the real handler (200, or 301→200), anchors to existing ids integration (built tree) SUMMERCMS_REQUIRE_BUILD=1 go test ./... -run TestLandingLinks (plugin) ❌ Wave 0
SC3 external links 200 anonymously (Source, golem15.com) network check at cutover SUMMERCMS_CHECK_EXTERNAL=1 go test ./... -run TestExternalLinks ❌ Wave 0
SC4 terminal commands run verbatim and end in handled=true scripted e2e SUMMERCMS_TERMINAL_CHECK=1 [SUMMERCMS_CLONE_URL=../summercms.go] go test -run TestTerminalCommands . (app) ❌ Wave 0
SC4 build script produces a linux/amd64 binary with both trees embedded scripted scripts/build.sh dev && file bin/summercms-io ❌ Wave 0
SC4 nginx config is syntactically valid scripted (local nginx, temp self-signed certs at substituted paths) nginx -t -p $TMP -c $TMP/nginx.conf ❌ Wave 0
SC4 clean-server bring-up manual (rome) DEPLOY.md walk-through, a human checkpoint n/a
D-25 DB suites green on PG 15 scripted throwaway export + sed + go test (see §PostgreSQL 15) ✅ procedure verified
D-41 site_url parsed; header link rendered only when set; unset output byte-identical; bad schemes rejected; --site-url override unit go test ./internal/docsite -run 'TestParseSite|TestSiteURL' and go test ./cmd/summer -run TestDocsTree partial (extend load_test.go, theme_test.go)
SC5 coverage of the new Go code unit go test -cover ./... in plugin and app ❌ Wave 0

Sampling Rate

  • Per task commit: the touched repo's go vet ./... && go test ./... (framework: add -short for speed except on D-25/D-41 tasks). Site: pnpm generate when Nuxt files change.
  • Per plan merge: framework full go test ./... (Docker); scripts/build.sh dev; plugin tests with SUMMERCMS_REQUIRE_BUILD=1; node --test.
  • Phase gate: all of the above, plus SUMMERCMS_TERMINAL_CHECK=1 with a local clone override, scripts/smoke.sh on postgres:15, nginx -t of the shipped config, and the manual browser UAT. External links and verbatim clone are checked at cutover after D-38.

Wave 0 Gaps

  • vue-summercmsio-app/tests/{output,scrollSpy,terminal}.test.ts and the "test" script
  • plugins/golem15/summercms/{static_test.go,routes_test.go,links_test.go}, with fixtures via fstest.MapFS
  • sm-summercmsio-app/terminal_check_test.go, scripts/smoke.sh, scripts/check-nginx.sh (optional)
  • framework: extend internal/docsite/load_test.go (site_url cases) and theme_test.go (header link present/absent)

Security Domain

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication no (no auth surface; admin not activated) —
V3 Session Management no —
V4 Access Control yes (keep the admin unreachable) No admin controllers means no /backend routes. nginx location ^~ /backend { return 404; } and limit_except GET HEAD.
V5 Input Validation yes (request paths; site_url value) path.Clean + fs.Sub(embed.FS) + dot-segment refusal; site_url scheme allow-list; html/template attribute escaping
V6 Cryptography yes (TLS only) certbot / Let's Encrypt at nginx. The app key is generated with key:generate.
V7 Error Handling/Logging yes recoverBare (no stack to client); supervisor log file
V9 Communications yes HTTPS-only with an HTTP→HTTPS 301; HSTS optional at nginx
V10 Malicious Code / supply chain yes Pinned npm versions + committed pnpm-lock.yaml + --frozen-lockfile; allowBuilds limited; no new Go deps
V14 Configuration yes Secrets only in server .env (0600, owner summercms), never rsynced from git. Dedicated PG role owning a single DB. Loopback-only listen.

Known Threat Patterns

Pattern STRIDE Standard Mitigation
Path traversal / dotfile disclosure (/docs/.summer-docs, /../) Information disclosure Clean path, fs.Sub root, refuse . segments
Open redirect via the .html / /docs redirects Spoofing Location built from the cleaned relative path with a fixed /docs/ prefix
MIME sniffing of user-agent-supplied content Tampering Explicit Content-Type + X-Content-Type-Options: nosniff (no user content is served)
Admin exposure Elevation of privilege Admin never activated + nginx deny
Secret leakage into git/rsync Information disclosure .env outside config/, .gitignored, mode 0600
Clickjacking of a static marketing page Tampering Low value. Optional X-Frame-Options: SAMEORIGIN. Do not add boardwalk's CSP (it breaks Nuxt inline scripts).
Dependency compromise in the npm tree Tampering Lockfile, frozen install, versions already in production use

Sources

Primary (HIGH confidence)

  • Framework code read this session:
    • modules/surf/{serve.go,router.go,clientip.go}
    • modules/cabana/{http.go,prefix.go}
    • modules/boardwalk/boardwalk.go
    • modules/pact/capabilities.go
    • modules/party/registry.go
    • modules/lagoon/{commands.go,connection.go,encrypted.go,migrations.go,postgres_test.go}
    • modules/lagoon/attach/bucket.go
    • modules/conga/{worker.go,client.go}
    • modules/bonfire/prompts.go
    • modules/compass/README.md
    • internal/build/{build.go,manifest.go,scaffold.go,leaf.go}
    • internal/docsite/{load.go,emit.go,serve.go,docsite.go,check_forbidden.go}
    • internal/docsite/theme/templates/header.html
    • cmd/summer/{docs.go,runtime.go,main.go,docs_test.go}
    • docs/site.yaml, docs/console/utilities.md, docs/setup/installation.md
    • README.md
    • examples/hello/*
  • Executed this session:
    • PG15 suite run
    • surf catch-all probe
    • real docs:build --base-url /docs (+ git-archive determinism)
    • D-40 command run in a temp clone
    • Nuxt 4 spike (4 generate runs)
    • live-site fetch
    • git.golem15.com anonymous probe
    • GOROOT mime table
  • ../fonoteka.go/{go.work,go.mod,summer.yaml,config/*}; /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/{package.json,nuxt.config.ts,pnpm-workspace.yaml,.nvmrc,i18n/,patches/} and installed versions.
  • [CITED: go.dev/ref/mod#go-work-file-replace]: go.work replace overrides workspace module replaces.
  • [CITED: pkg.go.dev/embed]: _/. exclusion, the all: prefix, no symlinks, a pattern must match files.

Secondary (MEDIUM confidence)

  • npm registry (npm view) for latest versions; gsd package-legitimacy seam verdicts.

Tertiary (LOW confidence)

  • Rome server specifics (nginx version, cert lineage, free port): not observable from here.

Metadata

Confidence breakdown:

  • Standard stack: HIGH. Versions are installed in fonoteka and were rebuilt in the spike.
  • Architecture: HIGH. Routing, docs layout and boot requirements were exercised against real code.
  • Pitfalls: HIGH for 1, 2, 7–12 (reproduced or read). MEDIUM for 3, 5, 6 (spec knowledge, needs browser UAT).
  • Deploy: MEDIUM. The live site was observed, but rome internals are assumed.

Research date: 2026-10-01 Valid until: 2026-10-31. The Nuxt ecosystem moves fast: @nuxtjs/i18n and nuxt-og-image published on 2026-09-30. Re-check if versions are unpinned.