Skip to content

Commit 285a216

Browse files
docgen
1 parent c598b72 commit 285a216

4 files changed

Lines changed: 425 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ spage.log
1616
generated_tasks.go
1717
generated_tasks
1818
test.conf
19+
/docgen
1920

2021
# pre-commit prettier hook
2122
node_modules

cmd/docgen/main.go

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
package main
2+
3+
import (
4+
"flag"
5+
"fmt"
6+
"go/ast"
7+
"go/parser"
8+
"go/token"
9+
"os"
10+
"path/filepath"
11+
"reflect"
12+
"sort"
13+
"strings"
14+
15+
pkg "github.com/AlexanderGrooff/spage/pkg"
16+
_ "github.com/AlexanderGrooff/spage/pkg/modules"
17+
)
18+
19+
type fieldDoc struct {
20+
Name string
21+
YamlTag string
22+
TypeString string
23+
Comment string
24+
Required bool
25+
}
26+
27+
func typeString(t reflect.Type) string {
28+
if t == nil {
29+
return "<nil>"
30+
}
31+
// Deref pointer for readability
32+
for t.Kind() == reflect.Ptr {
33+
t = t.Elem()
34+
}
35+
switch t.Kind() {
36+
case reflect.Slice:
37+
return "[" + typeString(t.Elem()) + "]"
38+
case reflect.Map:
39+
return fmt.Sprintf("map[%s]%s", typeString(t.Key()), typeString(t.Elem()))
40+
case reflect.Interface:
41+
// Keep interface for e.g. apt.Name
42+
return "any"
43+
case reflect.Struct:
44+
return t.Name()
45+
default:
46+
return t.String()
47+
}
48+
}
49+
50+
func parseStructComments(structName, dir string) map[string]string {
51+
comments := map[string]string{}
52+
fset := token.NewFileSet()
53+
// Parse all files in dir for comments
54+
pkgs, _ := parser.ParseDir(fset, dir, nil, parser.ParseComments)
55+
for _, p := range pkgs {
56+
for _, f := range p.Files {
57+
for _, decl := range f.Decls {
58+
gd, ok := decl.(*ast.GenDecl)
59+
if !ok || gd.Tok != token.TYPE {
60+
continue
61+
}
62+
for _, spec := range gd.Specs {
63+
ts, ok := spec.(*ast.TypeSpec)
64+
if !ok || ts.Name == nil || ts.Name.Name != structName {
65+
continue
66+
}
67+
st, ok := ts.Type.(*ast.StructType)
68+
if !ok {
69+
continue
70+
}
71+
for _, field := range st.Fields.List {
72+
name := ""
73+
if len(field.Names) > 0 {
74+
name = field.Names[0].Name
75+
} else {
76+
// embedded field
77+
continue
78+
}
79+
c := strings.TrimSpace(fieldComment(field))
80+
if c != "" {
81+
comments[name] = c
82+
}
83+
}
84+
}
85+
}
86+
}
87+
}
88+
return comments
89+
}
90+
91+
func fieldComment(f *ast.Field) string {
92+
if f.Comment != nil {
93+
return f.Comment.Text()
94+
}
95+
if f.Doc != nil {
96+
return f.Doc.Text()
97+
}
98+
return ""
99+
}
100+
101+
func yamlTag(sf reflect.StructField) (string, bool) {
102+
tag := sf.Tag.Get("yaml")
103+
if tag == "" {
104+
return "", false
105+
}
106+
parts := strings.Split(tag, ",")
107+
return parts[0], true
108+
}
109+
110+
func fieldsFromType(t reflect.Type, srcDir string) []fieldDoc {
111+
// Deref pointer
112+
for t.Kind() == reflect.Ptr {
113+
t = t.Elem()
114+
}
115+
if t.Kind() != reflect.Struct {
116+
return nil
117+
}
118+
comments := parseStructComments(t.Name(), srcDir)
119+
out := make([]fieldDoc, 0, t.NumField())
120+
for i := 0; i < t.NumField(); i++ {
121+
sf := t.Field(i)
122+
if sf.PkgPath != "" { // unexported
123+
continue
124+
}
125+
ytag, hasTag := yamlTag(sf)
126+
// Only include fields that explicitly have a yaml tag (and not '-') and are not anonymous
127+
if !hasTag || ytag == "-" || sf.Anonymous {
128+
continue
129+
}
130+
fd := fieldDoc{
131+
Name: sf.Name,
132+
YamlTag: ytag,
133+
TypeString: typeString(sf.Type),
134+
Comment: comments[sf.Name],
135+
Required: false, // heuristics later
136+
}
137+
out = append(out, fd)
138+
}
139+
sort.Slice(out, func(i, j int) bool { return out[i].YamlTag < out[j].YamlTag })
140+
return out
141+
}
142+
143+
type paramDoc struct {
144+
fieldDoc
145+
Extra pkg.ParameterDoc
146+
}
147+
148+
func mergeParamDocs(fields []fieldDoc, docs map[string]pkg.ParameterDoc) []paramDoc {
149+
byYaml := map[string]fieldDoc{}
150+
for _, f := range fields {
151+
key := f.YamlTag
152+
if key == "" {
153+
key = f.Name
154+
}
155+
byYaml[key] = f
156+
}
157+
keys := make([]string, 0, len(byYaml))
158+
for k := range byYaml {
159+
keys = append(keys, k)
160+
}
161+
sort.Strings(keys)
162+
out := make([]paramDoc, 0, len(keys))
163+
for _, k := range keys {
164+
var extra pkg.ParameterDoc
165+
if docs != nil {
166+
if d, ok := docs[k]; ok {
167+
extra = d
168+
}
169+
}
170+
out = append(out, paramDoc{fieldDoc: byYaml[k], Extra: extra})
171+
}
172+
return out
173+
}
174+
175+
func writeModuleDoc(moduleName string, mod pkg.Module, repoRoot, docsRoot string) error {
176+
inputT := mod.InputType()
177+
outputT := mod.OutputType()
178+
179+
// Try to guess source dir for the module based on package path
180+
// All modules are in spage/pkg/modules
181+
srcDir := filepath.Join(repoRoot, "pkg", "modules")
182+
183+
inputFields := fieldsFromType(inputT, srcDir)
184+
outputFields := fieldsFromType(outputT, srcDir)
185+
var inputParamDocs []paramDoc
186+
if pdp, ok := mod.(pkg.ParameterDocsProvider); ok {
187+
inputParamDocs = mergeParamDocs(inputFields, pdp.ParameterDocs())
188+
} else {
189+
inputParamDocs = mergeParamDocs(inputFields, nil)
190+
}
191+
192+
b := &strings.Builder{}
193+
fmt.Fprintf(b, "### %s module\n\n", moduleName)
194+
195+
// Optional module-provided docs via interface
196+
if mdp, ok := mod.(pkg.ModuleDocProvider); ok {
197+
fmt.Printf("Using module-provided docs for %s\n", moduleName)
198+
content := mdp.Doc()
199+
if strings.TrimSpace(content) != "" {
200+
if !strings.HasSuffix(content, "\n\n") {
201+
if strings.HasSuffix(content, "\n") {
202+
content += "\n"
203+
} else {
204+
content += "\n\n"
205+
}
206+
}
207+
fmt.Fprint(b, content)
208+
}
209+
}
210+
211+
// Inputs table
212+
fmt.Fprintf(b, "**Inputs**\n\n")
213+
if len(inputParamDocs) == 0 {
214+
fmt.Fprintf(b, "No input parameters.\n\n")
215+
} else {
216+
fmt.Fprintln(b, "| Parameter | Type | Description | Required | Default | Choices |")
217+
fmt.Fprintln(b, "|---|---|---|---|---|---|")
218+
for _, p := range inputParamDocs {
219+
f := p.fieldDoc
220+
name := f.YamlTag
221+
if name == "" { // fallback to struct name
222+
name = f.Name
223+
}
224+
desc := strings.TrimSpace(strings.ReplaceAll(f.Comment, "\n", " "))
225+
if p.Extra.Description != "" {
226+
desc = p.Extra.Description
227+
}
228+
required := ""
229+
if p.Extra.Required != nil {
230+
if *p.Extra.Required {
231+
required = "true"
232+
} else {
233+
required = "false"
234+
}
235+
}
236+
def := p.Extra.Default
237+
choices := ""
238+
if len(p.Extra.Choices) > 0 {
239+
choices = strings.Join(p.Extra.Choices, ", ")
240+
}
241+
fmt.Fprintf(b, "| %s | %s | %s | %s | %s | %s |\n", name, f.TypeString, desc, required, def, choices)
242+
}
243+
fmt.Fprintln(b)
244+
}
245+
246+
// Outputs table
247+
fmt.Fprintf(b, "**Outputs**\n\n")
248+
if len(outputFields) == 0 {
249+
fmt.Fprintf(b, "No outputs.\n\n")
250+
} else {
251+
fmt.Fprintln(b, "| Field | Type | Description |")
252+
fmt.Fprintln(b, "|---|---|---|")
253+
for _, f := range outputFields {
254+
// Skip embedded ModuleOutput
255+
if f.Name == "ModuleOutput" || f.YamlTag == "" && f.TypeString == "ModuleOutput" {
256+
continue
257+
}
258+
name := f.YamlTag
259+
if name == "" {
260+
name = f.Name
261+
}
262+
desc := strings.TrimSpace(strings.ReplaceAll(f.Comment, "\n", " "))
263+
fmt.Fprintf(b, "| %s | %s | %s |\n", name, f.TypeString, desc)
264+
}
265+
fmt.Fprintln(b)
266+
}
267+
268+
// Parameter aliases if any
269+
aliases := mod.ParameterAliases()
270+
if len(aliases) > 0 {
271+
fmt.Fprintf(b, "**Parameter aliases**\n\n")
272+
// stable order
273+
ks := make([]string, 0, len(aliases))
274+
for k := range aliases {
275+
ks = append(ks, k)
276+
}
277+
sort.Strings(ks)
278+
for _, k := range ks {
279+
fmt.Fprintf(b, "- **%s** → %s\n", k, aliases[k])
280+
}
281+
fmt.Fprintln(b)
282+
}
283+
284+
// Write file
285+
// normalize name to base name (strip collection prefix)
286+
base := moduleName
287+
if strings.Contains(base, ".") {
288+
parts := strings.Split(base, ".")
289+
base = parts[len(parts)-1]
290+
}
291+
outPath := filepath.Join(docsRoot, base+".md")
292+
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
293+
return err
294+
}
295+
fmt.Printf("Writing doc for %s to %s\n", moduleName, outPath)
296+
return os.WriteFile(outPath, []byte(b.String()), 0o644)
297+
}
298+
299+
func main() {
300+
repoRoot := flag.String("repo", ".", "path to spage repo root")
301+
docsDir := flag.String("out", "../spage-docs/docs/modules", "output docs directory")
302+
only := flag.String("only", "", "only this module name")
303+
flag.Parse()
304+
305+
mods := pkg.ListRegisteredModules()
306+
// stable order unique base names; prefer canonical names without collection prefix
307+
names := make([]string, 0, len(mods))
308+
for name := range mods {
309+
if *only != "" && name != *only {
310+
continue
311+
}
312+
names = append(names, name)
313+
}
314+
sort.Strings(names)
315+
316+
// Prefer writing docs for canonical names once; skip collection aliases when base already processed
317+
seenBase := map[string]bool{}
318+
for _, name := range names {
319+
base := name
320+
if strings.Contains(base, ".") {
321+
parts := strings.Split(base, ".")
322+
base = parts[len(parts)-1]
323+
}
324+
if seenBase[base] {
325+
continue
326+
}
327+
mod := mods[name]
328+
if err := writeModuleDoc(base, mod, *repoRoot, *docsDir); err != nil {
329+
fmt.Fprintf(os.Stderr, "error generating doc for %s: %v\n", name, err)
330+
os.Exit(1)
331+
}
332+
seenBase[base] = true
333+
}
334+
}

pkg/module.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,29 @@ type Module interface {
107107
ParameterAliases() map[string]string
108108
}
109109

110+
// ModuleDocProvider is an optional interface that a Module can implement to
111+
// provide additional Markdown documentation (description, examples, notes).
112+
// If implemented, tooling like docgen can surface this content in generated docs.
113+
type ModuleDocProvider interface {
114+
Doc() string
115+
}
116+
117+
// ParameterDoc carries additional documentation for a single input parameter.
118+
// All fields are optional; tooling will merge these with reflected types and comments.
119+
type ParameterDoc struct {
120+
Description string // Human-readable description
121+
Required *bool // Whether the parameter is required (nil = unspecified)
122+
Default string // Default value rendered as text
123+
Choices []string // Enumerated valid values, if applicable
124+
}
125+
126+
// ParameterDocsProvider is an optional interface that a Module can implement
127+
// to provide enriched documentation for its input parameters.
128+
// The map key should be the YAML parameter name (e.g., "name", "state").
129+
type ParameterDocsProvider interface {
130+
ParameterDocs() map[string]ParameterDoc
131+
}
132+
110133
var registeredModules = make(map[string]Module)
111134

112135
// RegisterModule allows modules to register themselves by name.
@@ -122,3 +145,14 @@ func GetModule(name string) (Module, bool) {
122145
module, ok := registeredModules[name]
123146
return module, ok
124147
}
148+
149+
// ListRegisteredModules returns a copy of the registered modules map.
150+
// This function is useful for tooling like documentation generators that
151+
// need to introspect available modules without mutating the registry.
152+
func ListRegisteredModules() map[string]Module {
153+
out := make(map[string]Module, len(registeredModules))
154+
for k, v := range registeredModules {
155+
out[k] = v
156+
}
157+
return out
158+
}

0 commit comments

Comments
 (0)