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

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.

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 only body_limits: {default_bytes: 1048576, upload_bytes: 1048576} (numeric, YAML-only) and trusted_proxies: [] (consider 127.0.0.1/32 for 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 has true; the site has no jobs).
  • database.yaml: dsn: "" (env SUMMER_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