initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:03:50 +02:00
commit 23816d00f7
71 changed files with 14497 additions and 0 deletions

565
backend/docgen/render.go Normal file
View file

@ -0,0 +1,565 @@
package docgen
import (
"fmt"
"sort"
"strings"
)
// Render produces the full Markdown page for a project, wrapped in generated
// markers. The output always carries the three level-2 audience headings that
// go-doc's reader keys off, so an imported page is immediately navigable.
func Render(p *Project) (string, error) {
view := buildView(p)
var b builder
b.h1(p.Name)
b.para(p.Summary)
b.table(
[]string{"", ""},
[]row{
{"Module", view.ModuleSummary},
{"Endpoints", view.EndpointCount},
{"Components", view.ComponentCount},
{"Tests", view.TestSummary},
{"Go lines", view.LineSummary},
},
)
b.note("Everything between the generated markers is rewritten by every sync. " +
"Notes written outside the markers are kept.")
users(&b, view)
operators(&b, view)
developers(&b, view)
return Wrap(p.Name, p.Signature, strings.TrimRight(b.String(), "\n")), nil
}
// ---------------------------------------------------------------------------
// Audience sections
// ---------------------------------------------------------------------------
func users(b *builder, v docView) {
b.h2("For Users")
b.h3("What this application does")
b.para(v.Summary)
if v.HasReadme {
b.para(fmt.Sprintf(
"The project ships its own README at `%s`, which is the long form of this page. "+
"The three audience tabs split the same facts three ways so you only read the part "+
"that concerns you.", v.Readme))
} else {
b.para("This project ships no README, so the summary above was derived from the source " +
"tree. If that is wrong, write a README and re-run the sync.")
}
b.h3("Reading this in go-doc")
b.para("Open the page and pick the tab that matches you:")
b.bullets([]string{
"**User** — you use the finished application. This section is all you need.",
"**Operator** — you run it. Configuration, ports and deployment notes are under that tab.",
"**Developer** — you integrate with it or change it. The API and layout tables are under that tab.",
})
b.para("The feedback panel stores a note per audience, tagged with your badge, so operators " +
"and developers can see which audience raised it.")
if len(v.Docs) > 0 {
b.h3("Where the original documentation lives")
b.para(fmt.Sprintf(
"This project already carries %s. They stay in the project repository: this page "+
"points at them instead of copying them, so there is always exactly one copy of "+
"the truth.", plural(len(v.Docs), "Markdown document", "Markdown documents")))
rows := make([]row, 0, len(v.Docs))
for _, doc := range v.Docs {
rows = append(rows, row{"`" + doc.Path + "`", fmt.Sprintf("%d bytes", doc.Bytes)})
}
b.table([]string{"Document", "Size"}, rows)
}
}
func operators(b *builder, v docView) {
b.rule()
b.h2("For Operators")
b.h3("Running it locally")
if len(v.MakeTargets) > 0 {
b.para(fmt.Sprintf("The root Makefile defines %s:",
plural(len(v.MakeTargets), "target", "targets")))
b.bullets(backtickEach(v.MakeTargets, "make "))
} else {
b.para("This project has no root Makefile, so the binary or container is run directly.")
}
if len(v.Compose) > 0 {
b.h3("Local containers")
b.para("The compose file defines these services:")
rows := make([]row, 0, len(v.Compose))
for _, service := range v.Compose {
image := "built locally"
if service.Image != "" {
image = "`" + service.Image + "`"
}
ports := "internal only"
if service.Ports != "" {
ports = service.Ports
}
rows = append(rows, row{service.Name, image, ports})
}
b.table([]string{"Service", "Image", "Published ports"}, rows)
}
if v.Docker != nil {
b.h3("Image")
var sentence strings.Builder
fmt.Fprintf(&sentence, "`%s` builds from", v.Docker.File)
for i, base := range v.Docker.Bases {
if i > 0 {
sentence.WriteString(",")
}
fmt.Fprintf(&sentence, " `%s`", base)
}
if len(v.Docker.Stages) > 0 {
fmt.Fprintf(&sentence, " in stages: %s", strings.Join(v.Docker.Stages, ", "))
}
if len(v.Docker.Exposed) > 0 {
fmt.Fprintf(&sentence, " and exposes %s", strings.Join(v.Docker.Exposed, ", "))
}
sentence.WriteString(".")
b.para(sentence.String())
}
b.h3("Configuration")
if len(v.EnvVars) > 0 {
b.para("Every environment variable found in the source or in a `.env` file:")
rows := make([]row, 0, len(v.EnvVars))
for _, env := range v.EnvVars {
example := "—"
if env.Example != "" {
example = "`" + env.Example + "`"
}
source := "source"
if env.FromEnv {
source = ".env file"
}
rows = append(rows, row{"`" + env.Name + "`", example, source})
}
b.table([]string{"Variable", "Example", "Found in"}, rows)
} else {
b.para("No environment variables were found in the source or in a `.env` file.")
}
if len(v.Flags) > 0 {
b.para("Command-line flags override the environment where both exist:")
rows := make([]row, 0, len(v.Flags))
for _, flag := range v.Flags {
decl := "-" + flag.Name
if flag.Default != "" {
decl += "=" + flag.Default
}
def := "—"
if flag.Default != "" {
def = "`" + flag.Default + "`"
}
usage := "—"
if flag.Usage != "" {
usage = collapse(flag.Usage, 90)
}
rows = append(rows, row{"`" + decl + "`", def, usage})
}
b.table([]string{"Flag", "Default", "Purpose"}, rows)
}
b.h3("Network surface")
if len(v.Ports) > 0 {
quoted := make([]string, 0, len(v.Ports))
for _, port := range v.Ports {
quoted = append(quoted, "`"+port+"`")
}
b.para("Ports referenced across the project: " + strings.Join(quoted, ", ") + ".")
} else {
b.para("No explicit port declaration was found. Check the README and the compose file " +
"before assuming a default.")
}
b.h3("Deployment artefacts")
if len(v.HelmCharts) > 0 {
b.para(fmt.Sprintf("Helm %s found:",
plural(len(v.HelmCharts), "chart", "charts")))
b.bullets(backtickEach(v.HelmCharts, ""))
} else {
b.para("No Helm chart was found, so deployment is expected to be plain Docker or a " +
"managed platform.")
}
if len(v.OperatorDocs) > 0 {
b.h3("Operations documentation that already exists")
b.para("Read these in place rather than duplicating them here:")
b.bullets(docBullets(v.OperatorDocs))
}
for _, note := range v.Notes {
b.note(note)
}
}
func developers(b *builder, v docView) {
b.rule()
b.h2("For Developers")
b.h3("Module layout")
if len(v.ModuleSummaries) > 0 {
b.bullets(v.ModuleSummaries)
} else {
b.para("No go.mod in this tree: the project may be vendored, a workspace member, or " +
"not Go at all.")
}
b.h3("HTTP API")
if len(v.Routes) > 0 {
b.para("Every route registration found in the source:")
rows := make([]row, 0, len(v.Routes))
for _, route := range v.Routes {
method := "ANY"
if route.Method != "" {
method = "`" + route.Method + "`"
}
handler := "—"
if route.Handler != "" {
handler = "`" + route.Handler + "`"
}
rows = append(rows, row{method, "`" + route.Path + "`", handler})
}
b.table([]string{"Method", "Path", "Handler"}, rows)
} else {
b.para("No route registrations were detected, which is expected for a CLI, a library " +
"or a worker.")
}
b.h3("Direct dependencies")
if deps := v.DirectDeps(); len(deps) > 0 {
items := make([]string, 0, len(deps))
for _, dep := range deps {
items = append(items, "`"+dep.Path+"` "+dep.Version)
}
b.bullets(items)
} else {
b.para("No third-party dependencies are declared.")
}
b.h3("Directory map")
if len(v.Dirs) > 0 {
rows := make([]row, 0, len(v.Dirs))
for _, dir := range v.Dirs {
rows = append(rows, row{
"`" + dir.Name + "/`",
fmt.Sprintf("%d", dir.Files),
fmt.Sprintf("%d", dir.Lines),
directoryPurpose(dir.Name),
})
}
b.table([]string{"Directory", "Files", "Lines", "Likely purpose"}, rows)
} else {
b.para("No first-level directories were found.")
}
b.h3("Languages")
if len(v.Languages) > 0 {
items := make([]string, 0, len(v.Languages))
for _, lang := range v.Languages {
items = append(items, fmt.Sprintf("**%s** — %s, %d lines", lang.Name,
plural(lang.Files, "file", "files"), lang.Lines))
}
b.bullets(items)
} else {
b.para("No source files were recognised.")
}
if len(v.DeveloperDocs) > 0 {
b.h3("Developer documentation that already exists")
b.bullets(docBullets(v.DeveloperDocs))
}
}
// ---------------------------------------------------------------------------
// Markdown builder
// ---------------------------------------------------------------------------
// builder assembles Markdown with predictable spacing. Block elements ask for a
// separator instead of writing blank lines by hand, so every block is preceded
// by exactly one blank line no matter which branch produced it — the failure
// mode this replaces was headings glued to the paragraph above them.
type builder struct {
out strings.Builder
lastBlank bool
}
func (b *builder) String() string { return b.out.String() }
// ensureBlank separates the next line from the previous one unless it already is.
func (b *builder) ensureBlank() {
if b.out.Len() == 0 || b.lastBlank {
return
}
b.out.WriteString("\n")
}
func (b *builder) line(text string) {
b.out.WriteString(text)
b.out.WriteString("\n")
b.lastBlank = text == ""
}
func (b *builder) h1(text string) { b.ensureBlank(); b.line("# " + text) }
func (b *builder) h2(text string) { b.ensureBlank(); b.line("## " + text) }
func (b *builder) h3(text string) { b.ensureBlank(); b.line("### " + text) }
func (b *builder) para(text string) { b.ensureBlank(); b.line(text) }
func (b *builder) note(text string) { b.ensureBlank(); b.line("> " + text) }
// rule writes a horizontal rule, which always gets its own block.
func (b *builder) rule() { b.ensureBlank(); b.line("---") }
// bullets writes a tight list: the separator goes before the list, never between
// its items.
func (b *builder) bullets(items []string) {
if len(items) == 0 {
return
}
b.ensureBlank()
for _, item := range items {
b.line("- " + item)
}
}
// row is one Markdown table row.
type row []string
// table writes a GitHub-flavoured table. Cell content is emitted verbatim: every
// caller has already added its own backticks, so the builder must not guess.
func (b *builder) table(headers []string, rows []row) {
b.ensureBlank()
cells := make([]string, len(headers))
for i, header := range headers {
cells[i] = header
}
b.line("| " + strings.Join(cells, " | ") + " |")
separators := make([]string, len(headers))
for i := range separators {
separators[i] = "---"
}
b.line("| " + strings.Join(separators, " | ") + " |")
for _, r := range rows {
b.line("| " + strings.Join(r, " | ") + " |")
}
}
// backtickEach renders values as a list of code-formatted bullet items.
func backtickEach(values []string, prefix string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
out = append(out, "`"+prefix+value+"`")
}
return out
}
// docBullets renders discovered documents as code-formatted bullets with their
// titles when they have one.
func docBullets(docs []DocFile) []string {
out := make([]string, 0, len(docs))
for _, doc := range docs {
item := "`" + doc.Path + "`"
if doc.Title != "" {
item += " — " + doc.Title
}
out = append(out, item)
}
return out
}
// ---------------------------------------------------------------------------
// View model
// ---------------------------------------------------------------------------
// docView is the pre-computed, presentation-ready view of a project.
type docView struct {
*Project
ModuleSummary string
EndpointCount string
ComponentCount string
TestSummary string
LineSummary string
HasReadme bool
OperatorDocs []DocFile
DeveloperDocs []DocFile
ModuleSummaries []string
Notes []string
}
// DirectDeps flattens the direct requirements of every module in the tree.
func (v docView) DirectDeps() []Dependency {
var out []Dependency
for _, m := range v.Modules {
out = append(out, m.Direct...)
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out
}
func buildView(p *Project) docView {
v := docView{Project: p}
switch module := primaryModule(p); {
case module == "":
v.ModuleSummary = "no go.mod in this tree"
default:
parts := []string{module}
for _, m := range p.Modules {
if m.GoVersion != "" {
parts = append(parts, "go "+m.GoVersion)
break
}
}
parts = append(parts, plural(p.Stats.GoFiles, "Go file", "Go files"))
v.ModuleSummary = strings.Join(parts, " · ")
}
v.EndpointCount = plural(len(p.Routes), "endpoint", "endpoints")
v.ComponentCount = plural(p.Stats.Components, "component directory", "component directories")
v.LineSummary = plural(p.Stats.TotalLines, "line", "lines")
v.HasReadme = p.Readme != ""
if p.Stats.TestFuncs > 0 {
v.TestSummary = fmt.Sprintf("%d in %s", p.Stats.TestFuncs,
plural(p.Stats.TestFiles, "file", "files"))
} else {
v.TestSummary = "none detected"
}
// Route existing project docs under the audience that most likely owns them,
// so the generated page points rather than duplicates.
for _, doc := range p.Docs {
lower := strings.ToLower(doc.Path)
switch {
case containsAny(lower, "operat", "runbook", "deploy", "infra", "install", "troubleshoot"):
v.OperatorDocs = append(v.OperatorDocs, doc)
case containsAny(lower, "architect", "develop", "api", "integration", "contract", "coding", "design"):
v.DeveloperDocs = append(v.DeveloperDocs, doc)
}
}
for _, m := range p.Modules {
var summary strings.Builder
summary.WriteString(m.File)
if m.Path != "" {
summary.WriteString(" — module " + m.Path)
}
if m.GoVersion != "" {
summary.WriteString(", go " + m.GoVersion)
}
fmt.Fprintf(&summary, ", %d direct and %d indirect dependencies", len(m.Direct), m.Indirect)
v.ModuleSummaries = append(v.ModuleSummaries, summary.String())
}
// Anything a reader should know before trusting the generated content.
if !v.HasReadme {
v.Notes = append(v.Notes,
"This project ships no README, so the summary was derived from the tree layout. Add one and re-run the sync.")
}
if len(p.Modules) == 0 {
v.Notes = append(v.Notes, "No go.mod was found, so this tree was analysed as plain source files.")
}
if p.Docker == nil {
v.Notes = append(v.Notes, "No Dockerfile was found at any level.")
}
if len(p.Routes) == 0 && len(p.Modules) > 0 {
v.Notes = append(v.Notes,
"No HTTP route registrations were detected. If this is a server, the API table is incomplete — check for routes built dynamically.")
}
return v
}
func containsAny(haystack string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(haystack, needle) {
return true
}
}
return false
}
// directoryPurpose guesses what a top-level directory is for. It is a heuristic
// on names only, and it admits ignorance rather than inventing an answer.
func directoryPurpose(name string) string {
switch strings.ToLower(name) {
case "internal":
return "unimportable application internals"
case "pkg":
return "public library packages"
case "cmd", "bin", "main":
return "binary entry points"
case "api":
return "HTTP handlers and routing"
case "server":
return "server entry point"
case "services", "service":
return "domain services"
case "web", "ui", "frontend", "client":
return "frontend assets"
case "bdd", "e2e", "tests", "test", "features":
return "tests"
case "deploy", "deployment", "charts", "helm", "k8s", "kubernetes":
return "deployment manifests"
case "docker", "scripts", "script", "hack":
return "operational tooling"
case "docs", "documentation":
return "project documentation"
case "migrations":
return "schema migrations"
case "contracts", "openapi", "proto", "schemas":
return "shared schemas or contracts"
case "data":
return "flat-file data"
case "dist", "build", "out":
return "build output"
case "examples", "example":
return "worked examples"
case "auth":
return "authentication"
case "config":
return "configuration loading"
case "security":
return "security controls"
case "store", "storage":
return "persistence layer"
case "database":
return "database access"
case "models", "model", "schema":
return "domain models"
case "middleware":
return "HTTP middleware"
case "templates", "views":
return "render templates"
case "assets", "static":
return "static assets"
case "feedback", "comments":
return "user feedback"
default:
return "unknown — inspect directly"
}
}