22 KiB
Phase 11.2: summercms.io Alpha 0.1 landing page on SummerCMS - Pattern Map
Mapped: 2026-10-01 Files analyzed: 27 (new or modified, across 4 repos) Analogs found: 22 / 27
Paths: FW = summercms/summercms.go (framework), APP = summercms/sm-summercmsio-app (new), PLUG = APP/plugins/golem15/summercms (new submodule, module git.golem15.com/golem15/sm-summercmsio-plugin), SITE = APP/vue-summercmsio-app (new submodule), FON = summercms/fonoteka.go, VFON = /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app. All framework analog paths below are git-tracked source (checked with git ls-files); none are mirrors.
File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
FW/internal/docsite/load.go (Site.SiteURL, SiteLabel, validation) |
config/model | transform | itself: Site.BaseURL + ParseSite (load.go:21-31, 88-100) |
exact |
FW/internal/docsite/emit.go (pageView.SiteURL/SiteLabel, baseView) |
utility | transform | itself: pageView.HomeURL / baseView (emit.go ~62-95) |
exact |
FW/internal/docsite/theme/templates/header.html |
template | transform | itself (wordmark anchor, line 4) | exact |
FW/internal/docsite/theme/assets/site.css (.site-link) |
style | - | existing .wordmark / .search-trigger rules |
exact |
FW/cmd/summer/docs.go (--site-url, --site-label on build and serve) |
controller (CLI) | request-response | itself: base-url flag (docs.go:31, 93, 128) |
exact |
FW/internal/docsite/load.go Options + override in load() |
config | transform | opts.BaseURL override (load.go:217-221) |
exact |
FW/docs/console/utilities.md |
docs | - | itself, the flags table (lines ~33-37) | exact |
FW/README.md, FW/docs/setup/installation.md (PG 15+) |
docs | - | in-place text edit | n/a |
FW/internal/docsite/load_test.go, theme_test.go |
test | - | TestParseSite table (load_test.go:11-50) |
exact |
PLUG/plugin.go |
plugin (route registrar) | request-response | FW/examples/hello/plugins/base/plugin.go + greeter/plugin.go Routes |
role-match |
PLUG/static.go |
handler (static file server) | file-I/O | FW/modules/boardwalk/boardwalk.go + FW/internal/docsite/serve.go:236-290 |
exact (idea), with deliberate deviations |
PLUG/public/README.md |
placeholder | - | none (embed needs a matching file) | no analog |
PLUG/go.mod |
config | - | FON/plugins/golem15/user/go.mod |
exact |
PLUG/static_test.go |
test | - | FW/modules/boardwalk/boardwalk_test.go (fstest + httptest) |
exact |
PLUG/routes_test.go |
test | - | FW/modules/surf/example_test.go:114-140 (surf.Assemble) and FW/examples/hello/hello_test.go:201 |
role-match |
PLUG/links_test.go |
test (integration, built tree) | file-I/O | boardwalk_test.go assetRef regex walk |
partial |
APP/go.mod, APP/go.work |
config | - | FON/go.mod, FON/go.work |
exact |
APP/summer.yaml |
config | - | FON/summer.yaml |
exact |
APP/main.go, APP/plugins.gen.go |
generated | - | FON/main.go, FON/plugins.gen.go (summer build output) |
exact (generated, do not hand-write) |
APP/config/{app,http,storage,database,queue}.yaml |
config | - | FON/config/*.yaml |
exact |
APP/.gitignore |
config | - | FON/.gitignore |
exact |
APP/scripts/build.sh |
script | batch | FON/scripts/check-openapi.sh (bash skeleton) + RESEARCH build outline |
role-match |
APP/terminal_check_test.go |
test (e2e) | batch | FW/examples/hello/hello_test.go |
partial |
APP/scripts/smoke.sh, APP/deploy/*.conf, APP/DEPLOY.md, APP/README.md |
script/config/docs | - | none in repo | no analog (use RESEARCH Deploy section) |
SITE/nuxt.config.ts, package.json, pnpm-workspace.yaml |
config | - | VFON/nuxt.config.ts, VFON/package.json, VFON/pnpm-workspace.yaml |
exact (subset) |
SITE/i18n/locales/en.json |
config (copy) | - | VFON/i18n/locales/en.json |
exact (location) |
SITE/app/components/*.vue, app/utils/*.ts, app/data/terminal.json, tests/*.test.ts, scripts/derive-images.sh |
component/utility/test | event-driven (scroll, clipboard) | none suitable (VFON uses Tailwind + shadcn; D-35 forbids) | no analog (use design handoff + RESEARCH Pattern 3) |
Pattern Assignments
FW/internal/docsite/load.go (config, transform) — D-41/D-46
Analog: itself. Add fields next to BaseURL (load.go, struct Site):
type Site struct {
Title string `yaml:"title"`
Description string `yaml:"description"`
BaseURL string `yaml:"base_url"`
// EditURL and SourceURL carry a {path} token ...
EditURL string `yaml:"edit_url"`
SourceURL string `yaml:"source_url"`
LLMSNotes []string `yaml:"llms_notes"`
Sections []Section `yaml:"sections"`
}
A struct field is mandatory: ParseSite decodes with yaml.DisallowUnknownField(). Validation goes with the existing checks in ParseSite, same error style:
if s.Title == "" {
return Site{}, fmt.Errorf("docsite: site config: title is required")
}
(add e.g. "docsite: site config: site_url must be http(s):// or start with /"; reject javascript: and //host).
CLI override pattern (load.go:217-221), mirror for site_url/site_label from Options:
s.cfg, s.cfgRaw = cfg, raw
s.base = strings.TrimRight(cfg.BaseURL, "/")
if opts.BaseURL != "" {
s.base = strings.TrimRight(opts.BaseURL, "/")
}
Validate the flag value through the same function as the yaml value. Label rule (D-46): explicit label, else URL host, else "Home" for relative.
FW/internal/docsite/emit.go (utility, transform)
Add SiteURL, SiteLabel string to pageView and fill them in baseView, which the 404 page also uses (baseView(nil)), so the link appears on every page:
func (s *site) baseView(current *Page) pageView {
return pageView{
Assets: s.url("assets"),
HomeURL: s.url("index.html"),
SearchIndex: s.url("search-index.json"),
LLMS: s.url("llms.txt"),
LLMSFull: s.url("llms-full.txt"),
Nav: s.nav(current),
}
}
Do not pass SiteURL through s.url() (that prefixes base_url); it is an absolute or root-relative link to a different site.
FW/internal/docsite/theme/templates/header.html
Current line 4 (insert after it, before header-spacer, so unset output is byte-identical):
<a class="wordmark" href="{{.HomeURL}}" aria-label="SummerCMS documentation home"><span class="wordmark-sun">{{template "icon-sun"}}</span><span class="wordmark-text">Summer<span class="wordmark-cms">CMS</span></span></a>
Insert: {{- if .SiteURL}}<a class="site-link" href="{{.SiteURL}}">{{template "icon-chevron-left"}}<span>{{.SiteLabel}}</span></a>{{end}}. Use {{- trimming carefully so the unset branch adds no whitespace; assert byte-identical output in the test.
FW/cmd/summer/docs.go (CLI)
Flag declaration (docs.go:31, repeat at :93 for docs:serve):
{Name: "base-url", Description: "Base URL for site links (overrides site.yaml base_url)"},
Flag read (docs.go:124-130):
func docsOptions(in bonfire.Input) docsite.Options {
opts := docsite.Options{Commands: docsCommands()}
opts.Root, _ = in.Flag("root")
opts.Src, _ = in.Flag("src")
opts.Out, _ = in.Flag("out")
opts.BaseURL, _ = in.Flag("base-url")
return opts
}
Add site-url / site-label the same way. Check whether docs:serve builds options through docsOptions too; if not, add the read there.
FW/docs/console/utilities.md
Extend the Flags cells of the docs:build and docs:serve rows (--root (default .), --src, --out, --base-url, --check) and add a short "site.yaml keys" paragraph. Example URL must be neutral (https://acme.example/), never summercms.io (CLAUDE.md docs rule).
FW/internal/docsite/load_test.go (test)
Analog TestParseSite (load_test.go:11-50): const valid = "title: Acme\ndescription: Acme docs.\nsections:\n - name: setup\n title: Setup\n" then a []struct{ name, raw, want string } table checked with strings.Contains(err.Error(), tc.want). Add rows valid + "site_url: javascript:alert(1)\n" etc., and a positive case. Header present/absent assertions go in theme_test.go next to existing render tests.
PLUG/plugin.go (plugin, request-response)
Analog: FW/examples/hello/plugins/base/plugin.go (lines 1-54) for the skeleton, greeter/plugin.go:59-76 for Routes.
Imports + interface assertions + embed (base/plugin.go:1-25):
import (
"embed"
"io/fs"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
var (
_ pact.HasConfig = (*Plugin)(nil)
...
)
//go:embed config
var configFS embed.FS
Use var _ pact.HasRoutes = (*Plugin)(nil) only. Do NOT add pact.HasAdminControllers (activates cabana, needs admin.jwt.secret). Embed must be //go:embed all:public (boardwalk.go:24 uses //go:embed all:dist for the same reason).
Lifecycle methods (base/plugin.go:30-34, 52-54):
func (p *Plugin) ID() string { return "golem15.hello" }
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 init() {
party.Register(&Plugin{})
}
ID becomes "golem15.summercms" (must be a string literal: summer plugin:add reads it from source).
Routes (greeter/plugin.go:59-68) — plain r.Get(path, func(w, req)); for the site wrap in r.GroupRaw("", nil, func(g pact.Router){ g.Get("/docs", ...); g.Get("/docs/{path...}", ...); g.Get("/", ...) }) per RESEARCH Pattern 2. Return the handler-construction error from Routes (fail closed when public/site/index.html is missing), as boardwalk's newHandler does:
raw, err := fs.ReadFile(root, "index.html")
if err != nil {
return nil, fmt.Errorf("boardwalk: dist/index.html: %w", err)
}
PLUG/static.go (handler, file-I/O)
Analogs: FW/modules/boardwalk/boardwalk.go (embed.FS + ServeContent + content-type table + cache by prefix) and FW/internal/docsite/serve.go:236-290 (public static: dot-segment refusal, dir index.html, 404.html with 404 status). The docsite handler is the closer semantic match (public site, real 404s); boardwalk gives the embed/cache mechanics.
Content-type table + fallback (boardwalk.go:34-43, 153-162) — copy and extend with .md, .webmanifest, .txt, .xml/.xsl:
var contentTypes = map[string]string{
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".woff2": "font/woff2",
".woff": "font/woff",
".svg": "image/svg+xml",
".json": "application/json",
}
func ContentType(name string) string {
ext := strings.ToLower(path.Ext(name))
if ct, ok := contentTypes[ext]; ok {
return ct
}
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return "application/octet-stream"
}
Sub-tree (boardwalk.go:46-48): fs.Sub(distFS, "dist") → fs.Sub(publicFS, "public/site") and "public/docs".
Serve-file + cache (boardwalk.go:135-148) — replace the single assets/ prefix with RESEARCH's cache table (_nuxt/ except _nuxt/builds/, and _fonts/ immutable; everything else no-cache), and preset an ETag header before ServeContent:
w.Header().Set("Content-Type", ContentType(name))
if strings.HasPrefix(name, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
http.ServeContent(w, r, path.Base(name), time.Time{}, bytes.NewReader(body))
Path cleaning + dot refusal + dir index + 404 page (serve.go:250-283):
clean := path.Clean("/" + r.URL.Path)
for _, seg := range strings.Split(clean, "/") {
if strings.HasPrefix(seg, ".") {
notFound(w, root)
return
}
}
...
if err == nil && info.IsDir() {
name = filepath.Join(name, "index.html")
...
func notFound(w http.ResponseWriter, root string) {
body, err := os.ReadFile(filepath.Join(root, "404.html"))
if err != nil {
http.NotFound(w, nil)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_, _ = io.Copy(w, bytes.NewReader(body))
}
Port to fs.Stat/fs.ReadFile on the embedded fs.FS. Add the D-47 step: for docs, if <p> is not found and <p>.html is a regular file, http.Redirect(w, r, "/docs/"+strings.TrimPrefix(clean,"/")+".html", http.StatusMovedPermanently) built only from the cleaned path. Method check is already done by ServeMux (GET/HEAD patterns), so serve.go's 405 block is not needed.
Do NOT copy: SetSecurityHeaders (boardwalk.go:167-173: noindex, CSP script-src 'self', DENY), RewriteIndex, the SPA fallback (boardwalk.go:118-126), serveIndex's no-store. Optional headers allowed: X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin.
PLUG/static_test.go (test)
Analog: FW/modules/boardwalk/boardwalk_test.go:1-44:
import (
"io/fs"
"net/http"
"net/http/httptest"
"path"
"regexp"
"strings"
"testing"
"testing/fstest"
)
func get(h http.Handler, target string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
return rec
}
Build fixtures with fstest.MapFS and call the unexported constructor (boardwalk splits Handler / newHandler(root fs.FS, ...) exactly for this — copy that split). Cases: RESEARCH Validation table row SC2.
PLUG/routes_test.go (test)
Analog: FW/modules/surf/example_test.go:114-140:
app := backpack.New(nil)
plugin := &BlogPlugin{}
if err := plugin.Register(app); err != nil { ... }
h, err := surf.Assemble(app, []party.Plugin{plugin})
...
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
backpack.New(nil) (nil config) skips the body_limits requirement. Add a fake plugin implementing admin controllers to prove no conflict; assert /backend hits the site catch-all when alone, /docs → 301, POST / → 405.
PLUG/links_test.go (integration on built tree, gated SUMMERCMS_REQUIRE_BUILD=1)
Reuse boardwalk_test.go's href extractor idea (line 17): assetRef = regexp.MustCompile((?:src|href)="([^"]+)"); resolve each internal href through the real handler (follow one 301, require 200) and each #id against id="..." in index.html. Also the drift guard: each command in SITE/app/data/terminal.json appears in public/site/index.html.
PLUG/go.mod
Analog: FON/plugins/golem15/user/go.mod: module ..., go 1.27.0, require git.golem15.com/golem15/summercms v0.0.0 (v0.1.0 after the tag), plus replace git.golem15.com/golem15/summercms => ../../../../summercms.go (same depth as fonoteka). No third-party requires (stdlib only).
APP/go.work, APP/go.mod, APP/summer.yaml, APP/.gitignore
Analog: FON/go.work:
go 1.27.0
toolchain go1.27.0
use (
.
./plugins/golem15/user
./plugins/golem15/fonoteka
)
→ use ( . ./plugins/golem15/summercms ). Does not use the framework.
FON/go.mod header:
module git.golem15.com/golem15/fonoteka
go 1.27.0
toolchain go1.27.0
replace git.golem15.com/golem15/summercms => ../summercms.go
require (
git.golem15.com/golem15/fonoteka/plugins/golem15/fonoteka v0.0.0
...
git.golem15.com/golem15/summercms v0.0.0
...
replace git.golem15.com/golem15/fonoteka/plugins/golem15/user => ./plugins/golem15/user
→ module git.golem15.com/golem15/sm-summercmsio-app, require summercms v0.1.0 (D-42) and sm-summercmsio-plugin v0.0.0 with replace ... => ./plugins/golem15/summercms. Prefer generating these with summer plugin:add plugins/golem15/summercms.
FON/summer.yaml:
module: git.golem15.com/golem15/fonoteka
binary: fonoteka
plugins:
- id: golem15.user
module: git.golem15.com/golem15/fonoteka/plugins/golem15/user
→ binary: summercms-io, one plugin golem15.summercms / git.golem15.com/golem15/sm-summercmsio-plugin.
FON/.gitignore: /bin/, /tmp/, *.exe, go.work.sum → add .env, /storage/.
main.go / plugins.gen.go: generated by summer build; commit them as fonoteka does, never hand-edit.
APP/config/*.yaml
Analogs from FON/config/ (copy shape, change values):
app.yaml:name,debug: false,locale: en,fallback_locale: en,key: ""with the comment "Set SUMMER_APP__KEY to a 32-byte base64 value (summer key:generate)".http.yaml: keep onlybody_limits: {default_bytes: 1048576, upload_bytes: 1048576}(numeric, YAML-only) andtrusted_proxies: [](consider127.0.0.1/32for nginx); drop fonoteka's CORS block.storage.yaml:uploads.bucket_url: "file://./storage/app/uploads"(empty fails boot).queue.yaml:work_in_serve: false(fonoteka hastrue; the site has no jobs).database.yaml:dsn: ""(envSUMMER_DATABASE__DSN).
APP/scripts/build.sh (script, batch)
Analog skeleton: FON/scripts/check-openapi.sh:1-9:
#!/usr/bin/env bash
# <purpose>
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
plus explicit if [[ ! -f ... ]]; then echo "build: ... " >&2; exit 1; fi guards between steps. Body: RESEARCH §Code Examples "Build script outline" (nuxt generate → rsync .output/public/ → public/site/; git archive $TAG → docs:build --base-url /docs --site-url / --site-label summercms.io --out $PLUG/public/docs; plugin tests with SUMMERCMS_REQUIRE_BUILD=1; summer build then CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build). Never rsync the dist symlink.
APP/terminal_check_test.go (e2e, gated SUMMERCMS_TERMINAL_CHECK=1)
Partial analog FW/examples/hello/hello_test.go (exercises the hello binary). Use os/exec with bash -euo pipefail -c, temp GOBIN first on PATH, GOWORK unset, stdin /dev/null, optional SUMMERCMS_CLONE_URL substitution; read commands from vue-summercmsio-app/app/data/terminal.json (RESEARCH Pattern 3).
SITE/nuxt.config.ts, package.json, pnpm-workspace.yaml, i18n/locales/en.json
Analog: VFON/nuxt.config.ts — take only the module trio and their config shapes; drop Tailwind, pinia, shadcn, PWA, runtimeConfig proxies.
Modules + fonts (VFON lines ~64-87):
compatibilityDate: '2025-07-15',
devtools: { enabled: false },
modules: [ ..., '@nuxt/fonts', '@nuxtjs/i18n', '@nuxtjs/seo', ... ],
fonts: {
processCSSVariables: true,
families: [
{ name: 'IBM Plex Mono', weights: [400, 500], global: true },
],
},
i18n (VFON lines ~182-203): strategy: 'prefix_except_default', locales: [{ code: 'en', language: 'en-US', file: 'en.json', name: 'English' }], langDir: 'locales/', baseUrl, experimental: { prerenderMessages: true }. Drop detectBrowserLanguage (single locale).
site (VFON lines ~220-225): site: { url, name, description, defaultLocale }.
Then add the spike-verified extras from RESEARCH Pattern 1: styles: ['normal'], subsets: ['latin','latin-ext'], ogImage: { enabled: false }, sitemap: { autoI18n: false }, linkChecker.excludeLinks, nitro.prerender.ignore: ['/docs'].
package.json scripts (VFON lines 5-15): keep build, dev, generate, preview, prepare, typecheck; add "test": "node --test tests/*.test.ts". Pin versions per RESEARCH Standard Stack.
pnpm-workspace.yaml (VFON): copy allowBuilds minus protobufjs (and sharp, unused); do not copy patchedDependencies unless pnpm dev crashes.
i18n/locales/en.json: same location as VFON/i18n/locales/en.json.
Shared Patterns
Plugin skeleton (compiled, init-registered)
Source: FW/examples/hello/plugins/base/plugin.go:27-54
Apply to: PLUG/plugin.go. Interface compile-time assertions block, ID/Requires/Register/Boot, func init() { party.Register(&Plugin{}) }.
Static serving with embed.FS
Source: FW/modules/boardwalk/boardwalk.go:24-48, 135-162 (mechanics) + FW/internal/docsite/serve.go:236-290 (public semantics).
Apply to: PLUG/static.go. all: embed, fs.Sub, own MIME table first, http.ServeContent, cache by path prefix, dot-segment refusal, 404.html with status 404.
Error message style
Source: boardwalk.go:63-71, load.go ParseSite.
Apply to: all new Go code. fmt.Errorf("<pkg>: <what>: %w", err) with a lowercase package prefix (summercms: public/site/index.html missing; run scripts/build.sh).
Tests
Source: boardwalk_test.go (httptest + fstest.MapFS, get helper), load_test.go (table of {name, raw, want} with strings.Contains).
Apply to: plugin and framework tests. Plain func TestX(t *testing.T), stdlib only; env-gated integration tests (SUMMERCMS_REQUIRE_BUILD, SUMMERCMS_TERMINAL_CHECK, SUMMERCMS_CHECK_EXTERNAL) call t.Skip when unset.
App wiring
Source: FON/go.work, FON/go.mod, FON/summer.yaml, FON/config/*.yaml.
Apply to: all APP/ config files. Local replace to ../summercms.go, plugin replace to ./plugins/..., secrets empty in YAML and supplied by .env on the server.
No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
SITE/app/components/*.vue, app/assets/css/{tokens,base}.css |
component/style | event-driven | VFON uses Tailwind/shadcn (forbidden by D-35); follow design/README.md + SummerCMS Landing.dc.html and RESEARCH Design Handoff Inventory |
SITE/app/utils/{scrollSpy,terminal}.ts, app/data/terminal.json, tests/*.test.ts |
utility/test | transform | No node:test usage anywhere; use RESEARCH Pattern 3 and Validation table |
SITE/scripts/derive-images.sh |
script | batch | ImageMagick one-off; no precedent |
APP/DEPLOY.md, APP/deploy/nginx-summercms.io.conf, APP/deploy/supervisor-summercms-io.conf, APP/scripts/smoke.sh |
docs/config | - | No nginx/supervisor configs in any repo; use RESEARCH §Deploy items 1-9 |
PLUG/public/README.md |
placeholder | - | Required so //go:embed all:public matches before the first build; never place anything inside public/docs/ (docs:build refuses to clean it without .summer-docs) |
Metadata
Analog search scope: summercms.go/{modules/boardwalk,modules/surf,internal/docsite,cmd/summer,examples/hello,docs/console}, fonoteka.go/{go.work,go.mod,summer.yaml,.gitignore,config,scripts,plugins/golem15/user/go.mod}, vue-fonoteka-app/{nuxt.config.ts,package.json,pnpm-workspace.yaml,i18n/locales}
Files scanned: ~25
Pattern extraction date: 2026-10-01