| 1 | package dochtml |
| 2 | |
| 3 | import ( |
| 4 | "embed" |
| 5 | "html/template" |
| 6 | "io" |
| 7 | "io/fs" |
| 8 | "net/http" |
| 9 | "sort" |
| 10 | "strings" |
| 11 | |
| 12 | "go.bigb.es/curator/internal/godoc" |
| 13 | ) |
| 14 | |
| 15 | //go:embed templates/* static/* |
| 16 | var content embed.FS |
| 17 | |
| 18 | // StaticFS returns the embedded static assets filesystem. |
| 19 | func StaticFS() http.FileSystem { |
| 20 | sub, _ := fs.Sub(content, "static") |
| 21 | return http.FS(sub) |
| 22 | } |
| 23 | |
| 24 | // IndexEntry represents an entry in the package index. |
| 25 | type IndexEntry struct { |
| 26 | Anchor string |
| 27 | Text string |
| 28 | Children []IndexEntry // constructors and methods (for types) |
| 29 | } |
| 30 | |
| 31 | // ModulePageData is the template data for module/package pages. |
| 32 | type ModulePageData struct { |
| 33 | Host string |
| 34 | ImportPrefix string |
| 35 | ModuleName string |
| 36 | VCS string |
| 37 | Repo string |
| 38 | Version string |
| 39 | SubPath string |
| 40 | Versions []string |
| 41 | |
| 42 | // Package doc fields. |
| 43 | Doc template.HTML |
| 44 | Synopsis string |
| 45 | PackageName string |
| 46 | Index []IndexEntry |
| 47 | Consts []ValueData |
| 48 | Vars []ValueData |
| 49 | Funcs []FuncData |
| 50 | Types []TypeData |
| 51 | SubPackages []godoc.SubPkgSummary |
| 52 | SourceFiles []string |
| 53 | } |
| 54 | |
| 55 | // ValueData wraps godoc.ValueDoc for templates. |
| 56 | type ValueData struct { |
| 57 | Names []string |
| 58 | Doc template.HTML |
| 59 | Decl template.HTML |
| 60 | } |
| 61 | |
| 62 | // FuncData wraps godoc.FuncDoc for templates. |
| 63 | type FuncData struct { |
| 64 | Name string |
| 65 | Doc template.HTML |
| 66 | Decl template.HTML |
| 67 | Recv string |
| 68 | SourceFile string |
| 69 | SourceLine int |
| 70 | } |
| 71 | |
| 72 | // TypeData wraps godoc.TypeDoc for templates. |
| 73 | type TypeData struct { |
| 74 | Name string |
| 75 | Doc template.HTML |
| 76 | Decl template.HTML |
| 77 | Consts []ValueData |
| 78 | Vars []ValueData |
| 79 | Funcs []FuncData |
| 80 | Methods []FuncData |
| 81 | SourceFile string |
| 82 | SourceLine int |
| 83 | } |
| 84 | |
| 85 | // SourcePageData is the template data for source file view. |
| 86 | type SourcePageData struct { |
| 87 | Host string |
| 88 | ImportPrefix string |
| 89 | ModuleName string |
| 90 | VCS string |
| 91 | Repo string |
| 92 | Version string |
| 93 | SubPath string |
| 94 | FileName string |
| 95 | HighlightedSource template.HTML |
| 96 | SourceFiles []string |
| 97 | } |
| 98 | |
| 99 | // VersionsPageData is the template data for the versions list. |
| 100 | type VersionsPageData struct { |
| 101 | Host string |
| 102 | ImportPrefix string |
| 103 | ModuleName string |
| 104 | VCS string |
| 105 | Repo string |
| 106 | Versions []string |
| 107 | CurrentVersion string |
| 108 | } |
| 109 | |
| 110 | // Renderer handles HTML rendering of documentation pages. |
| 111 | type Renderer struct { |
| 112 | moduleTmpl *template.Template |
| 113 | sourceTmpl *template.Template |
| 114 | versionsTmpl *template.Template |
| 115 | } |
| 116 | |
| 117 | // NewRenderer creates a renderer with parsed templates. |
| 118 | func NewRenderer() (*Renderer, error) { |
| 119 | funcs := template.FuncMap{ |
| 120 | "hasPrefix": strings.HasPrefix, |
| 121 | } |
| 122 | base, err := template.New("base").Funcs(funcs).Parse(mustRead("templates/base.html")) |
| 123 | if err != nil { |
| 124 | return nil, err |
| 125 | } |
| 126 | |
| 127 | moduleTmpl, err := template.Must(base.Clone()).Parse(mustRead("templates/module.html")) |
| 128 | if err != nil { |
| 129 | return nil, err |
| 130 | } |
| 131 | |
| 132 | sourceTmpl, err := template.Must(base.Clone()).Parse(mustRead("templates/source.html")) |
| 133 | if err != nil { |
| 134 | return nil, err |
| 135 | } |
| 136 | |
| 137 | versionsTmpl, err := template.Must(base.Clone()).Parse(mustRead("templates/versions.html")) |
| 138 | if err != nil { |
| 139 | return nil, err |
| 140 | } |
| 141 | |
| 142 | return &Renderer{ |
| 143 | moduleTmpl: moduleTmpl, |
| 144 | sourceTmpl: sourceTmpl, |
| 145 | versionsTmpl: versionsTmpl, |
| 146 | }, nil |
| 147 | } |
| 148 | |
| 149 | // RenderModule renders a module/package documentation page. |
| 150 | func (r *Renderer) RenderModule(w io.Writer, data *ModulePageData) error { |
| 151 | return r.moduleTmpl.Execute(w, data) |
| 152 | } |
| 153 | |
| 154 | // RenderSource renders a source code view page. |
| 155 | func (r *Renderer) RenderSource(w io.Writer, data *SourcePageData) error { |
| 156 | return r.sourceTmpl.Execute(w, data) |
| 157 | } |
| 158 | |
| 159 | // RenderVersions renders a version list page. |
| 160 | func (r *Renderer) RenderVersions(w io.Writer, data *VersionsPageData) error { |
| 161 | return r.versionsTmpl.Execute(w, data) |
| 162 | } |
| 163 | |
| 164 | // BuildModulePageData creates template data from a PackageDoc. |
| 165 | func BuildModulePageData(host, modName, version, subpath, vcs, repo string, doc *godoc.PackageDoc, versions []string, subPkgs []godoc.SubPkgSummary) *ModulePageData { |
| 166 | data := &ModulePageData{ |
| 167 | Host: host, |
| 168 | ImportPrefix: host + "/" + modName, |
| 169 | ModuleName: modName, |
| 170 | VCS: vcs, |
| 171 | Repo: repo, |
| 172 | Version: version, |
| 173 | SubPath: subpath, |
| 174 | Versions: versions, |
| 175 | SubPackages: subPkgs, |
| 176 | } |
| 177 | |
| 178 | if doc != nil { |
| 179 | data.Doc = template.HTML(doc.Doc) |
| 180 | data.Synopsis = doc.Synopsis |
| 181 | data.PackageName = doc.Name |
| 182 | data.SourceFiles = doc.Files |
| 183 | |
| 184 | // Build symbol map for cross-referencing in declarations. |
| 185 | symbols := make(map[string]string) |
| 186 | for _, t := range doc.Types { |
| 187 | symbols[t.Name] = "#" + t.Name |
| 188 | for _, c := range t.Consts { |
| 189 | for _, name := range c.Names { |
| 190 | symbols[name] = "#" + name |
| 191 | } |
| 192 | } |
| 193 | for _, v := range t.Vars { |
| 194 | for _, name := range v.Names { |
| 195 | symbols[name] = "#" + name |
| 196 | } |
| 197 | } |
| 198 | for _, f := range t.Funcs { |
| 199 | symbols[f.Name] = "#" + f.Name |
| 200 | } |
| 201 | } |
| 202 | for _, f := range doc.Funcs { |
| 203 | symbols[f.Name] = "#" + f.Name |
| 204 | } |
| 205 | for _, c := range doc.Consts { |
| 206 | for _, name := range c.Names { |
| 207 | symbols[name] = "#" + name |
| 208 | } |
| 209 | } |
| 210 | for _, v := range doc.Vars { |
| 211 | for _, name := range v.Names { |
| 212 | symbols[name] = "#" + name |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | // Build hierarchical index: funcs sorted, then types sorted |
| 217 | // with constructors and methods as children. |
| 218 | var funcIndex []IndexEntry |
| 219 | for _, f := range doc.Funcs { |
| 220 | funcIndex = append(funcIndex, IndexEntry{ |
| 221 | Anchor: f.Name, |
| 222 | Text: f.ShortDecl, |
| 223 | }) |
| 224 | } |
| 225 | sort.Slice(funcIndex, func(i, j int) bool { |
| 226 | return funcIndex[i].Text < funcIndex[j].Text |
| 227 | }) |
| 228 | |
| 229 | var typeIndex []IndexEntry |
| 230 | for _, t := range doc.Types { |
| 231 | entry := IndexEntry{ |
| 232 | Anchor: t.Name, |
| 233 | Text: "type " + t.Name, |
| 234 | } |
| 235 | |
| 236 | // Constructors (sorted). |
| 237 | var constructors []IndexEntry |
| 238 | for _, f := range t.Funcs { |
| 239 | constructors = append(constructors, IndexEntry{ |
| 240 | Anchor: f.Name, |
| 241 | Text: f.ShortDecl, |
| 242 | }) |
| 243 | } |
| 244 | sort.Slice(constructors, func(i, j int) bool { |
| 245 | return constructors[i].Text < constructors[j].Text |
| 246 | }) |
| 247 | entry.Children = append(entry.Children, constructors...) |
| 248 | |
| 249 | // Type-associated constants (sorted). |
| 250 | var consts []IndexEntry |
| 251 | for _, c := range t.Consts { |
| 252 | for _, name := range c.Names { |
| 253 | consts = append(consts, IndexEntry{ |
| 254 | Anchor: name, |
| 255 | Text: "const " + name, |
| 256 | }) |
| 257 | } |
| 258 | } |
| 259 | for _, v := range t.Vars { |
| 260 | for _, name := range v.Names { |
| 261 | consts = append(consts, IndexEntry{ |
| 262 | Anchor: name, |
| 263 | Text: "var " + name, |
| 264 | }) |
| 265 | } |
| 266 | } |
| 267 | sort.Slice(consts, func(i, j int) bool { |
| 268 | return consts[i].Text < consts[j].Text |
| 269 | }) |
| 270 | entry.Children = append(entry.Children, consts...) |
| 271 | |
| 272 | // Methods (sorted). |
| 273 | var methods []IndexEntry |
| 274 | for _, m := range t.Methods { |
| 275 | methods = append(methods, IndexEntry{ |
| 276 | Anchor: t.Name + "." + m.Name, |
| 277 | Text: m.ShortDecl, |
| 278 | }) |
| 279 | } |
| 280 | sort.Slice(methods, func(i, j int) bool { |
| 281 | return methods[i].Text < methods[j].Text |
| 282 | }) |
| 283 | entry.Children = append(entry.Children, methods...) |
| 284 | typeIndex = append(typeIndex, entry) |
| 285 | } |
| 286 | sort.Slice(typeIndex, func(i, j int) bool { |
| 287 | return typeIndex[i].Text < typeIndex[j].Text |
| 288 | }) |
| 289 | |
| 290 | data.Index = append(funcIndex, typeIndex...) |
| 291 | |
| 292 | // Build import link map: alias → base doc URL. |
| 293 | // Local imports (under our host) link to our own docs. |
| 294 | // External imports link to pkg.go.dev with version from go.mod. |
| 295 | importLinks := make(map[string]string, len(doc.Imports)) |
| 296 | for alias, path := range doc.Imports { |
| 297 | if strings.HasPrefix(path, host+"/") { |
| 298 | // Local module: link to our doc page. |
| 299 | importLinks[alias] = "/" + strings.TrimPrefix(path, host+"/") |
| 300 | } else { |
| 301 | importLinks[alias] = pkgDevURL(path, doc.DepVersions) |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | // Convert godoc types to template types. |
| 306 | imports := importLinks |
| 307 | for _, c := range doc.Consts { |
| 308 | data.Consts = append(data.Consts, convertValue(c, symbols, imports)) |
| 309 | } |
| 310 | for _, v := range doc.Vars { |
| 311 | data.Vars = append(data.Vars, convertValue(v, symbols, imports)) |
| 312 | } |
| 313 | for _, f := range doc.Funcs { |
| 314 | data.Funcs = append(data.Funcs, convertFunc(f, symbols, imports)) |
| 315 | } |
| 316 | for _, t := range doc.Types { |
| 317 | data.Types = append(data.Types, convertType(t, symbols, imports)) |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | return data |
| 322 | } |
| 323 | |
| 324 | func hlDecl(s string, symbols map[string]string, imports map[string]string) template.HTML { |
| 325 | return template.HTML(godoc.HighlightDeclLinked(s, symbols, imports)) |
| 326 | } |
| 327 | |
| 328 | func convertValue(v godoc.ValueDoc, symbols, imports map[string]string) ValueData { |
| 329 | return ValueData{ |
| 330 | Names: v.Names, |
| 331 | Doc: template.HTML(v.Doc), |
| 332 | Decl: hlDecl(v.Decl, symbols, imports), |
| 333 | } |
| 334 | } |
| 335 | |
| 336 | func convertFunc(f godoc.FuncDoc, symbols, imports map[string]string) FuncData { |
| 337 | return FuncData{ |
| 338 | Name: f.Name, |
| 339 | Doc: template.HTML(f.Doc), |
| 340 | Decl: hlDecl(f.Decl, symbols, imports), |
| 341 | Recv: f.Recv, |
| 342 | SourceFile: f.SourceFile, |
| 343 | SourceLine: f.SourceLine, |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | func convertType(t godoc.TypeDoc, symbols, imports map[string]string) TypeData { |
| 348 | td := TypeData{ |
| 349 | Name: t.Name, |
| 350 | Doc: template.HTML(t.Doc), |
| 351 | Decl: hlDecl(t.Decl, symbols, imports), |
| 352 | SourceFile: t.SourceFile, |
| 353 | SourceLine: t.SourceLine, |
| 354 | } |
| 355 | for _, c := range t.Consts { |
| 356 | td.Consts = append(td.Consts, convertValue(c, symbols, imports)) |
| 357 | } |
| 358 | for _, v := range t.Vars { |
| 359 | td.Vars = append(td.Vars, convertValue(v, symbols, imports)) |
| 360 | } |
| 361 | for _, f := range t.Funcs { |
| 362 | td.Funcs = append(td.Funcs, convertFunc(f, symbols, imports)) |
| 363 | } |
| 364 | for _, m := range t.Methods { |
| 365 | td.Methods = append(td.Methods, convertFunc(m, symbols, imports)) |
| 366 | } |
| 367 | return td |
| 368 | } |
| 369 | |
| 370 | // TrustedHTML converts a string to template.HTML for pre-sanitized content. |
| 371 | func TrustedHTML(s string) template.HTML { |
| 372 | return template.HTML(s) |
| 373 | } |
| 374 | |
| 375 | func mustRead(name string) string { |
| 376 | data, err := content.ReadFile(name) |
| 377 | if err != nil { |
| 378 | panic("dochtml: " + err.Error()) |
| 379 | } |
| 380 | return strings.TrimSpace(string(data)) |
| 381 | } |
| 382 | |
| 383 | // pkgDevURL builds a pkg.go.dev URL for an import path, with version if known. |
| 384 | // For stdlib packages, uses the Go version. For third-party, finds the module |
| 385 | // version from go.mod. |
| 386 | func pkgDevURL(importPath string, depVersions map[string]string) string { |
| 387 | if depVersions == nil { |
| 388 | return "https://pkg.go.dev/" + importPath |
| 389 | } |
| 390 | |
| 391 | // Check if this is a stdlib package (no dots in first path element). |
| 392 | firstSlash := strings.Index(importPath, "/") |
| 393 | firstElem := importPath |
| 394 | if firstSlash > 0 { |
| 395 | firstElem = importPath[:firstSlash] |
| 396 | } |
| 397 | if !strings.Contains(firstElem, ".") { |
| 398 | // Stdlib package — use Go version. |
| 399 | if goVer, ok := depVersions["go"]; ok { |
| 400 | return "https://pkg.go.dev/" + importPath + "@go" + goVer |
| 401 | } |
| 402 | return "https://pkg.go.dev/" + importPath |
| 403 | } |
| 404 | |
| 405 | // Third-party: find the longest matching module path in depVersions. |
| 406 | // e.g., for "github.com/foo/bar/baz", try "github.com/foo/bar/baz", |
| 407 | // then "github.com/foo/bar", then "github.com/foo". |
| 408 | path := importPath |
| 409 | for path != "" { |
| 410 | if ver, ok := depVersions[path]; ok { |
| 411 | return "https://pkg.go.dev/" + importPath + "@" + ver |
| 412 | } |
| 413 | slash := strings.LastIndex(path, "/") |
| 414 | if slash < 0 { |
| 415 | break |
| 416 | } |
| 417 | path = path[:slash] |
| 418 | } |
| 419 | |
| 420 | return "https://pkg.go.dev/" + importPath |
| 421 | } |
| 422 | |