565 lines
16 KiB
Go
565 lines
16 KiB
Go
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"
|
|
}
|
|
}
|