Six plans (tracer generator, site UX and checkers, content A, content B, acme/blog walkthrough, unit tests). SC4/DOCS-04 narrowed to docs/ pages per D-18; README Go fence conversion logged as a todo.
35 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, assumption_delta_decision, user_setup, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | assumption_delta_decision | user_setup | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 11.1-summercms-documentation-for-humans-and-ai-agents | 01 | execute | 1 |
|
true |
|
no-change |
|
|
Purpose: prove the architecture end to end (D-01, D-02, D-03, D-05, D-06, D-07, D-08, D-17) before plan 11.1-02 adds the Winter-style theme and the accuracy checkers, and before the content plans write pages. Per D-14 this is plan 1 of 6.
Output: internal/docsite package, summer docs:build and summer docs:sync, docs/site.yaml, docs/index.md, docs/setup/installation.md, modules/bonfire/example_test.go, real-tree smoke tests in cmd/summer.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
type Options structwith fieldsRoot string(repository root;src=paths andmodules/resolve against it),Src string(docs source dir, default<Root>/docs),Out string(output dir, default<Root>/site),BaseURL string(overridessite.yamlbase_urlwhen non-empty). Plan 11.1-02 adds aCommandsfield.type Problem struct { File string; Line int; Rule string; Message string }withfunc (p Problem) String() stringreturningfile:line: rule: message(File repo-relative, forward slashes).type Result struct { Pages int; Out string },type SyncResult struct { Snippets, Files int }.func Check(opts Options) ([]Problem, error): load, verify and render in memory; writes nothing.func Build(opts Options) (Result, []Problem, error): runs Check; when problems exist it writes nothing and returns them; otherwise guards and cleans Out, then writes every file.func Sync(opts Options) (SyncResult, []Problem, error): rewrites driftedsrc=fence bodies under Src.type Site structdecoded strictly fromdocs/site.yaml:Title,Description,BaseURL(base_url),EditURL(edit_url, with a{path}token),SourceURL(source_url, with a{path}token),LLMSNotes []string(llms_notes),Sections []Section(sections, each{name, title}in sidebar order).type Frontmatter struct { Title, Description, Section string; Order int }(yaml keystitle,description,section,order, all required).type Page structwithSource(repo-relative source path),URL(extension-less path such assetup/installation,api/lagoon,index),Section,Title,Description,Order,Module(non-empty for api pages), and the Markdown body.type Ref struct { Path, Fragment string },func ParseSrc(info string) (Ref, bool),func Extract(root string, ref Ref) (string, error).const MarkerFile = ".summer-docs".
Reading order (used by pager, llms-full.txt and the AI sync test): index first, then each site.yaml section in order with its pages sorted by order, with the api section's pages sorted by module name.
- Create
internal/docsitewith the API in the plan's interfaces block.load.go: decodedocs/site.yamlwith goccy/go-yamlyaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())intoSite(the tide ParseManifest idiom, errors prefixeddocsite:). WalkSrcfor*.md, skippingexamples/,site.yamland any_-prefixed file or directory. Each page file must start with a---line; split frontmatter at the next---line and decode it strictly intoFrontmatter. Report UI-SPEC problem lines<file>:1: frontmatter: <detail>for: missing field, unknown field, section not equal to the directory name, duplicate order in a section (order N already used by <other>), first body line not# <title>, description over 160 characters.docs/index.mdis the only root-level page; its section is the reserved valueindex, it is not listed insite.yaml, and it is rendered first in reading order. Everysite.yamlsection must have at least one page, else reportdocs/site.yaml:<line>: section: "<name>" has no pages (add the section in the same change as its first page). render.go: onegoldmark.Newwithextension.GFMandparser.WithAutoHeadingID(). Do not enable goldmark's raw-HTML (unsafe) renderer option: raw HTML in Markdown stays escaped (T-11.1-02). Strip the page's# TitleH1 with an AST transformer so the template renders the title once.emit.go: render everything into an in-memory map of output path to bytes before touching disk. For each page write<URL>.htmlfromtheme/templates/page.html(html/template, embedded with//go:embed) and<URL>.md(no frontmatter;# Title, blank line,> description, blank line, body). Writellms.txt(H1# SummerCMS,>site description, thellms_notesas a bullet list, a## Overviewlist holding the index page, then one## <Section title>list per section with- [Title](<base>/<URL>.md): <description>),llms-full.txt(per page in reading order:# Title,Source: <base>/<URL>.html, blank line, description, blank line, the page .md body) andsearch-index.jsonshaped{"p":[{"u":"<base>/<URL>.html","t":"<title>","s":"<section title>"}],"e":[]}(theeheading entries arrive in Task 2). Copytheme/assets/site.csstoassets/site.css. URLs arebase_urlplus/plus the path; with an empty base they are root-relative.page.html:<html lang="en">,<title>"{Page title} · SummerCMS docs" (index: "SummerCMS documentation"),<meta name="description">,<link rel="stylesheet" href="{base}/assets/site.css">,<body class="docs">, a<nav class="sidebar" aria-label="Documentation">grouped by section insite.yamlorder with the current item markedaria-current="page", and<main id="content">with the H1 title, the description lead and the rendered body. Header, on-page TOC, pager, search, theme toggle and highlighting are plan 11.1-02 (UI-SPEC theme); this template is the shell that plan replaces.site.cssholds a readable two-column layout with the UI-SPEC light tokens only.- Output guard (T-11.1-03):
BuildrefusesOutequal toRoot, insideSrc, or an ancestor ofSrcorRoot(docs:build: --out must not be inside --src or equal to the repository root). An existing non-emptyOutwithout.summer-docsis refused withdocs:build: refusing to clean {out}: it has no .summer-docs marker. Remove the directory or choose another --out.; with the marker, remove its contents, write the files and re-create the marker. Never delete anything outsideOut. cmd/summer/docs.go:docsBuildCommand()nameddocs:build, description "Build the documentation site", string flagsroot(default.),src,out,base-url, and bare flagcheck(validate only, write nothing). It resolves defaults, callsdocsite.Build(ordocsite.Checkwith--check), prints each problem without.Printf("%s\n", p), thendocs:build: {n} problems, nothing writtenand returns a short error so the binary exits 1; on success printsdocs:build: wrote {n} pages to {out}. Register it intoolCommands(); adddocs:buildto theTestToolCommandNameswant list and--outto its helpWants.- Content:
docs/site.yamlwithtitle: SummerCMS,description"SummerCMS is a content management framework for Go, inspired by WinterCMS.",base_url: "",edit_url: "https://git.golem15.com/golem15/summercms/_edit/master/{path}",source_url: "https://git.golem15.com/golem15/summercms/src/branch/master/{path}",llms_notes(compiled plugins registered at build time; Postgres only; headless, no frontend themes; Go 1.27), andsectionslisting onlysetup(title "Setup") andapi(title "API reference").docs/index.md: frontmatter title "SummerCMS documentation", the UI-SPEC index description, sectionindex, order 0; a short landing body linkingsetup/installation.md.docs/setup/installation.md: title "Installation", sectionsetup, order 20; requirements (Go 1.27, PostgreSQL 16 with the ICU locale lagoon checks, Docker only for integration tests) and installing the tool withgo install ./cmd/summer, as prose andshfences. Use neutral names only (D-11). .gitignore: add/site/in the "# Go" block next to/dist/.cmd/summer/docs_test.go:TestDocsTreecallsdocsite.CheckwithRoot: "../.."and fails listing every problem line;TestDocsBuildRealTreerunsdocs:build --root ../.. --out <t.TempDir()>/sitethroughbonfire.NewRoot("summer", toolCommands(), &buf)and assertsindex.html,index.md,setup/installation.html,setup/installation.md,llms.txt,llms-full.txt,search-index.json,assets/site.cssand.summer-docsexist and the output line matchesdocs:build: wrote N pages to.
Commit rule for every task in this plan: another plan (11-08) may have uncommitted edits in the working tree; stage only the files this task lists (never git add -A). Do not modify modules/lagoon/transaction.go, modules/lagoon/transaction_test.go, modules/lagoon/README.md, modules/cabana/crud.go, modules/cabana/relation.go or modules/beachcomber/sync_test.go.
go vet ./internal/docsite/... ./cmd/summer && go test ./internal/docsite ./cmd/summer -run '^(TestDocsTree|TestDocsBuildRealTree|TestToolCommandNames)$' -count=1 -v
<fails_when>non-zero exit, a "--- FAIL" line, or "no tests to run" in the output</fails_when>
<acceptance_criteria>
- go test ./cmd/summer -run '^TestDocsBuildRealTree$' -count=1 -v prints --- PASS: TestDocsBuildRealTree.
- go run ./cmd/summer docs:build --out "$(mktemp -d)/site" exits 0 and prints a line starting docs:build: wrote .
- grep -n 'DisallowUnknownField' internal/docsite/load.go finds a match.
- grep -n '"docs:build"' cmd/summer/main_test.go finds a match.
- grep -n '^/site/$' .gitignore finds a match.
- ! grep -rn 'WithUnsafe' internal/docsite (no match).
- head -1 docs/setup/installation.md prints --- and the first line after the closing frontmatter delimiter is # Installation.
- Pointing --out at a scratch dir that holds an unrelated file makes docs:build exit non-zero and print has no .summer-docs marker, and the unrelated file still exists afterwards.
</acceptance_criteria>
summer docs:build writes HTML, .md siblings, llms.txt, llms-full.txt, search-index.json and assets for the index and installation pages from a strict source tree, refuses unsafe output dirs, and the real-tree smoke tests pass.
- Module discovery (
load.go): every directorymodules/<name>(top level only) that contains a non-_test.goGo file is a module. Missingmodules/<name>/README.mdis the problemmodules/<name>: readme: package has Go files but no README.md. The list is discovered at run time, never hard-coded, so Phase 11 modules (conga, lighthouse, flare, beachcomber) and any later module appear without code changes. Each README becomes a page with URLapi/<name>, sectionapi, title = the README H1 text, description = the first non-empty line after the H1, order = alphabetical by name; strip the H1 with the Task 1 transformer. TheSourceof these pages is the README path so problems cite it. - Slug IDs (
render.go): implement goldmark'sparser.IDs(Generate(value []byte, kind ast.NodeKind) []byte,Put(value []byte)) as a GitHub-compatible slugger: lowercase, keep Unicode letters, digits,_and-, map spaces to-, drop other punctuation, dedupe with-1,-2. Create a fresh instance per page and pass it withparser.NewContext(parser.WithIDs(ids)). Expose an unexported helper that returns the ID list for a page so emission, the search index and the plan 11.1-02 link checker share one algorithm. - Link rewriting (AST transformer in
render.go): in guide pages, a relative link to another page's.mdbecomes<base>/<URL>.htmlin HTML and<base>/<URL>.mdin the raw .md; a relative link tomodules/<m>/README.md(for example../../modules/postcard/README.md) becomes theapi/<m>page; in ingested READMEs../<m>/README.mdbecomes theapi/<m>page. Fragments are kept. Externalhttp(s)://andmailto:links are untouched. - Search index (
emit.go): add oneeentry per H2 heading{"p":<page index>,"a":"<id>","h":"<heading text>","x":"<plain text of that section, at most 300 characters>"}. Plain text is extracted from the AST (text nodes only), never from rendered HTML. - Sidebar: the
apisection lists every module page by name, and llms.txt gets## API referencewith one item per module. Extenddocs/site.yamlhandling only in code;site.yamlalready listsapi. - Tests (smoke level; full coverage is plan 11.1-06): in
internal/docsite/docsite_test.goaddTestSlugIDs(duplicates get-1/-2, punctuation dropped,_kept) andTestReadmeIngestionover at.TempDir()fixture (one module with README, one without: the second yields thereadme:problem). Incmd/summer/docs_test.goaddTestEveryModuleInSidebar(for each discoveredmodules/<m>with non-test Go files,api/<m>.htmlexists in a real-tree build and the sidebar HTML ofindex.htmllinks it) andTestDocsAIOutputsInSync(the set and order of pages fromdocsiteequals: the.htmlfiles, the.mdsiblings, the.mdlinks in llms.txt, and theSource:lines in llms-full.txt; llms.txt line 1 is# SummerCMS, the next non-empty line starts with>, and every H2 is followed by- [items).
Stage only this task's files.
go vet ./internal/docsite/... ./cmd/summer && go test ./internal/docsite ./cmd/summer -run '^(TestSlugIDs|TestReadmeIngestion|TestEveryModuleInSidebar|TestDocsAIOutputsInSync|TestDocsTree)$' -count=1 -v
<fails_when>non-zero exit, a "--- FAIL" line, or "no tests to run" in the output</fails_when>
<acceptance_criteria>
- go test ./cmd/summer -run '^TestEveryModuleInSidebar$' -count=1 -v prints --- PASS: TestEveryModuleInSidebar.
- go test ./cmd/summer -run '^TestDocsAIOutputsInSync$' -count=1 -v prints --- PASS: TestDocsAIOutputsInSync.
- grep -n 'parser.WithIDs' internal/docsite/render.go finds a match.
- After go run ./cmd/summer docs:build --out "$d", ls "$d/api" | grep -c '\.html$' equals the number of modules/* directories that hold a non-test .go file (22 at planning time; derive the number, do not hard-code it).
- grep -c '^## API reference$' "$d/llms.txt" prints 1.
- grep -o '"a":"[^"]*"' "$d/search-index.json" | head -1 prints a heading anchor entry.
</acceptance_criteria>
Every module README is an API reference page in the sidebar, llms.txt and search; heading IDs come from one slug implementation; the AI outputs match the page tree in order.
snippet.go:ParseSrc(info)splits the fence info string withstrings.Fieldsand returns thesrc=value split at the first#intoRef{Path, Fragment}. Three forms: no fragment = whole file;#Ident= top-level Go declaration with its doc comment, taken verbatim fromtoken.FileSetoffsets, except that anExample*function yields its body dedented with the trailing// Output:comment (as godoc shows it, viago/docExamples or equivalent);#region= the lines strictly between// docs:start regionand// docs:end region(# docs:start region/# docs:end regionin YAML), dedented, markers excluded.- Confinement (T-11.1-01), each a
snippet:problem at the fence line: path must be relative, clean (no..afterfilepath.Clean), resolve insideRootafterfilepath.EvalSymlinks, not start any segment with.(dotfiles,.env*), and for.gofiles sit in the root module, meaning no directory between the file andRootholds its owngo.mod(this excludesexamples/hello/**andadmin/**). AnExample*fragment without an// Output:comment is a problem (SC4 "run", not just compiled). A#regionor#Identfragment inside a_test.gofile must sit in a function that is aTest*orExample*function, or that is called from one in the test files of the same directory (checked with go/ast), so D-07's "compiled and run" holds. A fragment or whole file from non-test Go source must sit in a package directory that has_test.gofiles. - Drift: for every fence with
src=in pages underSrc, compare the fence body withExtractafter trailing-newline normalisation. Problems use the UI-SPEC wording:{file}:{line}: snippet: body differs from {src} (run: summer docs:sync)and{file}:{line}: snippet: {src} not found. Missing file, ident or region failsCheck, sodocs:buildwrites nothing (D-07). - Raw output: in each page's
.md, reduce a fence info string to its first word (dropsrc=). In HTML, render fences withsrc=inside<figure class="code">with a<figcaption>showingpath#fragmentlinked tosource_urlwith{path}filled (fragment not sent), then<pre><code>with the escaped body; fences withoutsrc=get the same figure without a caption. Sync(opts): rewrite each drifted fence body in place underSrc, preserving everything else byte for byte; reportdocs:sync: updated {n} snippets in {m} filesordocs:sync: all snippets up to date. AdddocsSyncCommand()nameddocs:sync(description "Rewrite src= code blocks from their sources", flagsroot,src) incmd/summer/docs.go, register it intoolCommands(), and add it to theTestToolCommandNameswant list.modules/bonfire/example_test.go(packagebonfire_test):ExampleCalldefines onebonfire.Commandnamedacme:greetwith anArgnamednamewhose Run printsHello, <name>throughout.Printf, callsbonfire.Call(context.Background(), cmds, "acme:greet", []string{"blog"}, os.Stdout), and ends with// Output: Hello, blog. go vet checks the Example name againstbonfire.Call.docs/setup/installation.md: add a "Check your install" section with ago src=modules/bonfire/example_test.go#ExampleCallfence whose body is produced by runninggo run ./cmd/summer docs:sync(never hand-typed), explaining that console commands are plainbonfire.Commandvalues.- Root
README.md: add adocs/row to the "Repository layout" table ("Documentation source;summer docs:buildrenders it into a static site.") and asummer docs:build/summer docs:syncline in "Development". Use neutral names only. - Smoke tests in
internal/docsite/docsite_test.go:TestSnippetForms(whole file,#Ident, Example body with Output,#region) andTestSnippetConfinement(absolute path,../x,.env, a path inside a nested-go.mod dir, a symlink escaping the root, an Example without Output, and a region inside a_test.gohelper that no Test or Example calls each yield asnippet:problem) overt.TempDir()fixtures;TestSyncRewritesDrift(a drifted fixture fence is rewritten and a second Sync reports up to date).
Stage only this task's files.
go vet ./... && go test ./internal/docsite ./cmd/summer ./modules/bonfire -count=1
<fails_when>non-zero exit or a "FAIL" line in the output</fails_when>
go test ./modules/bonfire -run '^ExampleCall$' -count=1 -v
<fails_when>non-zero exit, no "--- PASS: ExampleCall" line, or "no tests to run"</fails_when>
<acceptance_criteria>
- grep -n 'src=modules/bonfire/example_test.go#ExampleCall' docs/setup/installation.md finds a match.
- grep -n '// Output: Hello, blog' modules/bonfire/example_test.go finds a match.
- go run ./cmd/summer docs:sync prints docs:sync: all snippets up to date on a clean tree.
- Appending a character inside the installation page's Go fence body in a scratch copy of docs/ and running go run ./cmd/summer docs:build --check --src <scratch-docs> exits non-zero and prints snippet: body differs from modules/bonfire/example_test.go#ExampleCall (run: summer docs:sync).
- go test ./internal/docsite -run '^(TestSnippetForms|TestSnippetConfinement|TestSyncRewritesDrift)$' -count=1 -v prints three --- PASS lines.
- grep -n '"docs:sync"' cmd/summer/main_test.go finds a match.
- grep -n 'docs:build' README.md finds a match.
</acceptance_criteria>
Go fences in docs are verified copies of compiled, running source; drift or a missing source fails go test and the build; summer docs:sync repairs copies; the first Example runs under go test.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| docs/ Markdown and module READMEs → generator | Repository content becomes public HTML; authors are trusted but mistakes and pasted HTML are expected |
| src= references → repository files | A fence can name any path; the generator must not publish files outside the intended source set |
| generator → filesystem (--out) | The build deletes and rewrites a directory named on the command line |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-11.1-01 | Information disclosure | internal/docsite/snippet.go src= resolution | high | mitigate | Relative, cleaned, EvalSymlinks-confined paths; dot-segments (dotfiles, .env) and nested-go.mod dirs refused; TestSnippetConfinement plants each escape |
| T-11.1-02 | Tampering (XSS) | internal/docsite/render.go goldmark renderer | high | mitigate | goldmark raw-HTML option left off, so HTML in Markdown is escaped; chrome rendered by html/template with contextual escaping; the unsafe option is grep-gated absent |
| T-11.1-03 | Tampering (data loss) | internal/docsite Build output guard | high | mitigate | Refuse --out equal to root, inside or containing --src; clean only a dir holding the .summer-docs marker; nothing written when problems exist |
| T-11.1-04 | Tampering | docs/site.yaml and frontmatter decoding | low | mitigate | goccy/go-yaml DisallowUnknownField; every field required; typos fail as frontmatter problems |
| T-11.1-SC | Tampering | npm/pip/cargo/go installs | high | accept | This plan adds no module or package (goldmark and goccy/go-yaml are already in go.mod); the only new dependency of the phase is gated in plan 11.1-02 |
| </threat_model> |
9033d81 -- go.mod` prints nothing (no dependency change in this plan).
<success_criteria>
- SC1 (partial): docs/ holds frontmatter pages grouped by site.yaml sections; every module reachable from the sidebar through its api page.
- SC2 (partial): a
summerCLI command builds a self-contained static site with no Node toolchain. - SC3: llms.txt, llms-full.txt and a clean .md per page are emitted and TestDocsAIOutputsInSync asserts they match the page tree.
- SC4 (partial): the one Go example in docs is a src= copy of a running Example; drift fails go test. </success_criteria>
Artifacts this phase produces
- Package
internal/docsite:Options,Problem(String),Result,SyncResult,Check,Build,Sync,Site,Section,Frontmatter,Page,Ref,ParseSrc,Extract,MarkerFile; embeddedtheme/templates/page.html,theme/assets/site.css. - CLI commands:
summer docs:build(flags--root,--src,--out,--base-url,--check),summer docs:sync(flags--root,--src). - Example:
bonfire.ExampleCallinmodules/bonfire/example_test.go. - Files:
docs/site.yaml,docs/index.md,docs/setup/installation.md,cmd/summer/docs.go,cmd/summer/docs_test.go,internal/docsite/*.go,.gitignore/site/entry, README.md layout and development lines. - Output files per build:
<section>/<slug>.html,<section>/<slug>.md,index.html,index.md,api/<module>.html|.md,llms.txt,llms-full.txt,search-index.json,assets/site.css,.summer-docs. - Tests:
TestDocsTree,TestDocsBuildRealTree,TestEveryModuleInSidebar,TestDocsAIOutputsInSync(cmd/summer);TestSlugIDs,TestReadmeIngestion,TestSnippetForms,TestSnippetConfinement,TestSyncRewritesDrift(internal/docsite).