blob: 95348bb3581ffcaf8b8a919ae8f7fcf5eeef0656 [file] [log] [blame]
// Copyright 2019 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package dochtml renders Go package documentation into HTML.
//
// This package and its API are under development (see golang.org/issue/39883).
// The plan is to iterate on the development internally for x/pkgsite
// needs first, before factoring it out somewhere non-internal where its
// API can no longer be easily modified.
package dochtml
import (
"bytes"
"context"
"errors"
"fmt"
"go/ast"
"go/doc"
"go/printer"
"go/token"
"sort"
"strings"
"github.com/google/safehtml"
"github.com/google/safehtml/legacyconversions"
"github.com/google/safehtml/template"
"github.com/google/safehtml/uncheckedconversions"
"golang.org/x/pkgsite/internal"
"golang.org/x/pkgsite/internal/derrors"
"golang.org/x/pkgsite/internal/godoc/dochtml/internal/render"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
var (
// ErrTooLarge represents an error where the rendered documentation HTML
// size exceeded the specified limit. See the RenderOptions.Limit field.
ErrTooLarge = errors.New("rendered documentation HTML size exceeded the specified limit")
)
// ModuleInfo contains all the information a package needs about the module it
// belongs to in order to render its documentation.
type ModuleInfo struct {
ModulePath string
ResolvedVersion string
// ModulePackages is the set of all full package paths in the module.
ModulePackages map[string]bool
}
// RenderOptions are options for Render.
type RenderOptions struct {
// FileLinkFunc optionally specifies a function that
// returns a URL where file should be linked to.
// file is the name component of a .go file in the package,
// including the .go qualifier.
// As a special case, FileLinkFunc may return the empty
// string to indicate that a given file should not be linked.
FileLinkFunc func(file string) (url string)
SourceLinkFunc func(ast.Node) string
SinceVersionFunc func(name string) string
// ModInfo optionally specifies information about the module the package
// belongs to in order to render module-related documentation.
ModInfo *ModuleInfo
Limit int64 // If zero, a default limit of 10 megabytes is used.
BuildContext internal.BuildContext
}
// templateData holds the data passed to the HTML templates in this package.
type templateData struct {
RootURL string
Package *doc.Package
Consts, Vars, Funcs, Types []*item
Examples *examples
NoteHeaders map[string]noteHeader
}
// Parts contains HTML for each part of the documentation.
type Parts struct {
Body safehtml.HTML // main body of doc
Outline safehtml.HTML // outline for large screens
MobileOutline safehtml.HTML // outline for mobile
Links []render.Link // "Links" section of package doc
}
// Render renders package documentation HTML for the
// provided file set and package, in separate parts.
//
// If any of the rendered documentation part HTML sizes exceeds the specified limit,
// an error with ErrTooLarge in its chain will be returned.
func Render(ctx context.Context, fset *token.FileSet, p *doc.Package, opt RenderOptions) (_ *Parts, err error) {
defer derrors.Wrap(&err, "dochtml.RenderParts")
if opt.Limit == 0 {
const megabyte = 1000 * 1000
opt.Limit = 10 * megabyte
}
funcs, data, links := renderInfo(ctx, fset, p, opt)
p = data.Package
if docIsEmpty(p) {
return &Parts{}, nil
}
exec := func(tmpl *template.Template) safehtml.HTML {
if err != nil {
return safehtml.HTML{}
}
t := template.Must(tmpl.Clone()).Funcs(funcs)
var html safehtml.HTML
html, err = executeToHTMLWithLimit(t, data, opt.Limit)
return html
}
parts := &Parts{
Body: exec(bodyTemplate),
Outline: exec(outlineTemplate),
MobileOutline: exec(sidenavTemplate),
// links must be called after body, because the call to
// render_doc_extract_links in body.tmpl creates the links.
Links: links(),
}
if err != nil {
return nil, err
}
return parts, nil
}
// An item is rendered as one piece of documentation. It is essentially a union
// of the Value, Type and Func types from internal/doc, along with additional
// information for HTML rendering, like class names.
type item struct {
Doc string
Decl ast.Decl // GenDecl for consts, vars and types; FuncDecl for functions
Name string // for types and functions; empty for consts and vars
FullName string // for methods, the type name + "." + Name; else same as Name
HeaderStart string // text of header, before source link
Examples []*example // for types and functions; empty for vars and consts
IsDeprecated bool
Consts, Vars, Funcs, Methods []*item // for types
// HTML-specific values, for types and functions
Kind string // for data-kind attribute
HeaderClass string // class for header
}
func packageToItems(p *doc.Package, exmap map[string][]*example) (consts, vars, funcs, types []*item) {
consts = valuesToItems(p.Consts)
vars = valuesToItems(p.Vars)
funcs = funcsToItems(p.Funcs, "Documentation-functionHeader", "", exmap)
for _, t := range p.Types {
types = append(types, typeToItem(t, exmap))
}
return consts, vars, funcs, types
}
func valuesToItems(vs []*doc.Value) []*item {
var r []*item
for _, v := range vs {
r = append(r, valueToItem(v))
}
return r
}
func valueToItem(v *doc.Value) *item {
return &item{
Doc: v.Doc,
Decl: v.Decl,
IsDeprecated: valueIsDeprecated(v),
}
}
func funcsToItems(fs []*doc.Func, hclass, typeName string, exmap map[string][]*example) []*item {
var r []*item
for _, f := range fs {
fullName := f.Name
if typeName != "" {
fullName = typeName + "." + f.Name
}
kind := "function"
headerStart := "func"
if f.Recv != "" {
kind = "method"
headerStart += " (" + f.Recv + ")"
}
i := &item{
Doc: f.Doc,
Decl: f.Decl,
Name: f.Name,
FullName: fullName,
HeaderStart: headerStart,
IsDeprecated: funcIsDeprecated(f),
Examples: exmap[fullName],
Kind: kind,
HeaderClass: hclass,
}
r = append(r, i)
}
return r
}
func typeToItem(t *doc.Type, exmap map[string][]*example) *item {
return &item{
Name: t.Name,
FullName: t.Name,
Doc: t.Doc,
Decl: t.Decl,
HeaderStart: "type",
IsDeprecated: typeIsDeprecated(t),
Kind: "type",
HeaderClass: "Documentation-typeHeader",
Examples: exmap[t.Name],
Consts: valuesToItems(t.Consts),
Vars: valuesToItems(t.Vars),
Funcs: funcsToItems(t.Funcs, "Documentation-typeFuncHeader", "", exmap),
Methods: funcsToItems(t.Methods, "Documentation-typeMethodHeader", t.Name, exmap),
}
}
func docIsEmpty(p *doc.Package) bool {
return p.Doc == "" &&
len(p.Examples) == 0 &&
len(p.Consts) == 0 &&
len(p.Vars) == 0 &&
len(p.Types) == 0 &&
len(p.Funcs) == 0
}
// renderInfo returns the functions and data needed to render the doc.
func renderInfo(ctx context.Context, fset *token.FileSet, p *doc.Package, opt RenderOptions) (map[string]any, templateData, func() []render.Link) {
// Make a copy to avoid modifying caller's *doc.Package.
p2 := *p
p = &p2
// When rendering documentation for commands, display
// the package comment and notes, but no declarations.
if p.Name == "main" {
// Clear top-level declarations.
p.Consts = nil
p.Types = nil
p.Vars = nil
p.Funcs = nil
p.Examples = nil
}
// Remove everything from the notes section that is not a bug. This
// includes TODOs and other arbitrary notes.
for k := range p.Notes {
if k == "BUG" {
continue
}
delete(p.Notes, k)
}
r := render.New(ctx, fset, p, &render.Options{
PackageURL: func(path string) string {
// Use the same module version for imported packages that belong to
// the same module.
versionedPath := path
if opt.ModInfo != nil {
versionedPath = versionedPkgPath(path, opt.ModInfo)
}
var search string
if opt.BuildContext.GOOS != "" && opt.BuildContext.GOOS != "all" {
search = "?GOOS=" + opt.BuildContext.GOOS
}
return "/" + versionedPath + search
},
})
fileLink := func(name string) safehtml.HTML {
return linkHTML(name, opt.FileLinkFunc(name), "Documentation-file")
}
sourceLink := func(name string, node ast.Node) safehtml.HTML {
return linkHTML(name, opt.SourceLinkFunc(node), "Documentation-source")
}
sinceVersion := func(name string) safehtml.HTML {
return safehtml.HTMLEscaped(opt.SinceVersionFunc(name))
}
funcs := map[string]any{
"render_short_synopsis": r.ShortSynopsis,
"render_synopsis": r.Synopsis,
"render_doc": r.DocHTML,
"render_doc_extract_links": r.DocHTMLExtractLinks,
"render_decl": r.DeclHTML,
"render_code": r.CodeHTML,
"file_link": fileLink,
"source_link": sourceLink,
"since_version": sinceVersion,
}
examples := collectExamples(p)
data := templateData{
Package: p,
RootURL: "/pkg",
Examples: examples,
NoteHeaders: buildNoteHeaders(p.Notes),
}
data.Consts, data.Vars, data.Funcs, data.Types = packageToItems(p, examples.Map)
return funcs, data, r.Links
}
// executeToHTMLWithLimit executes tmpl on data and returns the result as a safehtml.HTML.
// It returns an error if the size of the result exceeds limit.
func executeToHTMLWithLimit(tmpl *template.Template, data any, limit int64) (safehtml.HTML, error) {
buf := &limitBuffer{B: new(bytes.Buffer), Remain: limit}
err := tmpl.Execute(buf, data)
if buf.Remain < 0 {
return safehtml.HTML{}, fmt.Errorf("dochtml.Render: %w", ErrTooLarge)
} else if err != nil {
return safehtml.HTML{}, fmt.Errorf("dochtml.Render: %v", err)
}
// This is safe because we're executing a safehtml template and not modifying the result afterwards.
// We're just doing what safehtml/template.Template.ExecuteToHTML does
// (https://github.com/google/safehtml/blob/b8ae3e5e1ce3/template/template.go#L136).
return uncheckedconversions.HTMLFromStringKnownToSatisfyTypeContract(buf.B.String()), nil
}
// linkHTML returns an HTML-formatted name linked to the given URL.
// The class argument is the class of the 'a' tag.
// If url is the empty string, the name is not linked.
func linkHTML(name, url, class string) safehtml.HTML {
if url == "" {
return safehtml.HTMLEscaped(name)
}
return render.ExecuteToHTML(render.LinkTemplate, render.Link{Class: class, Href: url, Text: name})
}
// examples is an internal representation of all package examples.
type examples struct {
List []*example // sorted by ParentID
Map map[string][]*example // keyed by top-level ID (e.g., "NewRing" or "PubSub.Receive") or empty string for package examples
}
// example is an internal representation of a single example.
type example struct {
*doc.Example
ID safehtml.Identifier // ID of example
ParentID string // ID of top-level declaration this example is attached to
Suffix string // optional suffix name in title case
}
// Code returns an printer.CommentedNode if ex.Comments is non-nil,
// otherwise it returns ex.Code as is.
func (ex *example) Code() any {
if len(ex.Comments) > 0 {
return &printer.CommentedNode{Node: ex.Example.Code, Comments: ex.Comments}
}
return ex.Example.Code
}
// WalkExamples calls fn for each Example in p,
// setting id to the name of the parent structure.
func WalkExamples(p *doc.Package, fn func(id string, ex *doc.Example)) {
for _, ex := range p.Examples {
fn("", ex)
}
for _, f := range p.Funcs {
for _, ex := range f.Examples {
fn(f.Name, ex)
}
}
for _, t := range p.Types {
for _, ex := range t.Examples {
fn(t.Name, ex)
}
for _, f := range t.Funcs {
for _, ex := range f.Examples {
fn(f.Name, ex)
}
}
for _, m := range t.Methods {
for _, ex := range m.Examples {
fn(t.Name+"."+m.Name, ex)
}
}
}
}
// collectExamples extracts examples from p
// into the internal examples representation.
func collectExamples(p *doc.Package) *examples {
exs := &examples{
List: nil,
Map: make(map[string][]*example),
}
WalkExamples(p, func(id string, ex *doc.Example) {
suffix := cases.Title(language.English, cases.NoLower).String(ex.Suffix)
ex0 := &example{
Example: ex,
ID: exampleID(id, suffix),
ParentID: id,
Suffix: suffix,
}
exs.List = append(exs.List, ex0)
exs.Map[id] = append(exs.Map[id], ex0)
})
sort.SliceStable(exs.List, func(i, j int) bool {
// TODO: Break ties by sorting by suffix, unless
// not needed because of upstream slice order.
return exs.List[i].ParentID < exs.List[j].ParentID
})
return exs
}
func exampleID(id, suffix string) safehtml.Identifier {
switch {
case id == "" && suffix == "":
return safehtml.IdentifierFromConstant("example-package")
case id == "" && suffix != "":
render.ValidateGoDottedExpr(suffix)
return legacyconversions.RiskilyAssumeIdentifier("example-package-" + suffix)
case id != "" && suffix == "":
render.ValidateGoDottedExpr(id)
return legacyconversions.RiskilyAssumeIdentifier("example-" + id)
case id != "" && suffix != "":
render.ValidateGoDottedExpr(id)
render.ValidateGoDottedExpr(suffix)
return legacyconversions.RiskilyAssumeIdentifier("example-" + id + "-" + suffix)
default:
panic("unreachable")
}
}
// noteHeader contains information the template needs to render
// the note related HTML tags in documentation page.
type noteHeader struct {
SafeIdentifier safehtml.Identifier
Label string
}
// buildNoteHeaders constructs note headers from note markers.
// It returns a map from each marker to its corresponding noteHeader.
func buildNoteHeaders(notes map[string][]*doc.Note) map[string]noteHeader {
headers := map[string]noteHeader{}
for marker := range notes {
headers[marker] = noteHeader{
SafeIdentifier: safehtml.IdentifierFromConstantPrefix("pkg-note", marker),
Label: cases.Title(language.Und).String(strings.ToLower(marker)),
}
}
return headers
}
// versionedPkgPath transforms package paths to contain the same version as the
// current module if the package belongs to the module. As a special case,
// versionedPkgPath will not add versions to standard library packages.
func versionedPkgPath(pkgPath string, modInfo *ModuleInfo) string {
if modInfo == nil || !modInfo.ModulePackages[pkgPath] {
return pkgPath
}
// We don't need to do anything special here for standard library packages
// since pkgPath will never contain the "std/" module prefix, and
// modInfo.ModulePackages contains this prefix for standard library packages.
innerPkgPath := pkgPath[len(modInfo.ModulePath):]
return fmt.Sprintf("%s@%s%s", modInfo.ModulePath, modInfo.ResolvedVersion, innerPkgPath)
}