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" } }