blob: d5e50d285bb762fe06fc66bdf9a091a29f978132 [file] [log] [blame]
// Copyright 2020 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 frontend
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"github.com/google/safehtml"
"golang.org/x/pkgsite/internal"
"golang.org/x/pkgsite/internal/derrors"
"golang.org/x/pkgsite/internal/experiment"
"golang.org/x/pkgsite/internal/godoc"
"golang.org/x/pkgsite/internal/godoc/dochtml"
"golang.org/x/pkgsite/internal/log"
"golang.org/x/pkgsite/internal/middleware"
"golang.org/x/pkgsite/internal/postgres"
)
// MainDetails contains data needed to render the unit template.
type MainDetails struct {
// NestedModules are nested modules relative to the path for the unit.
NestedModules []*NestedModule
// Subdirectories are packages in subdirectories relative to the path for
// the unit.
Subdirectories []*Subdirectory
// Licenses contains license metadata used in the header.
Licenses []LicenseMetadata
// NumImports is the number of imports for the package.
NumImports int
// CommitTime is time that this version was published, or the time that
// has elapsed since this version was committed if it was done so recently.
CommitTime string
// Readme is the rendered readme HTML.
Readme safehtml.HTML
// ReadmeOutline is a collection of headings from the readme file
// used to render the readme outline in the sidebar.
ReadmeOutline []*Heading
// ImportedByCount is the number of packages that import this path.
// When the count is > limit it will read as 'limit+'. This field
// is not supported when using a datasource proxy.
ImportedByCount string
DocBody safehtml.HTML
DocOutline safehtml.HTML
MobileOutline safehtml.HTML
IsPackage bool
// SourceFiles contains .go files for the package.
SourceFiles []*File
// RepositoryURL is the URL to the repository containing the package.
RepositoryURL string
// SourceURL is the URL to the source of the package.
SourceURL string
// ExpandReadme is holds the expandable readme state.
ExpandReadme bool
}
// File is a source file for a package.
type File struct {
Name string
URL string
}
// NestedModule is a nested module relative to the path of a given unit.
// This content is used in the Directories section of the unit page.
type NestedModule struct {
Suffix string // suffix after the unit path
URL string
}
// Subdirectory is a package in a subdirectory relative to the path of a given
// unit. This content is used in the Directories section of the unit page.
type Subdirectory struct {
Suffix string
URL string
Synopsis string
}
func fetchMainDetails(ctx context.Context, ds internal.DataSource, um *internal.UnitMeta, expandReadme bool) (_ *MainDetails, err error) {
defer middleware.ElapsedStat(ctx, "fetchMainDetails")()
unit, err := ds.GetUnit(ctx, um, internal.WithReadme|internal.WithDocumentation|internal.WithSubdirectories|internal.WithImports)
if err != nil {
return nil, err
}
nestedModules, err := getNestedModules(ctx, ds, um)
if err != nil {
return nil, err
}
subdirectories := getSubdirectories(um, unit.Subdirectories)
if err != nil {
return nil, err
}
readme, readmeOutline, err := readmeContent(ctx, unit)
if err != nil {
return nil, err
}
importedByCount, err := getImportedByCount(ctx, ds, unit)
if err != nil {
return nil, err
}
var (
docBody, docOutline, mobileOutline safehtml.HTML
files []*File
)
if unit.Documentation != nil {
end := middleware.ElapsedStat(ctx, "DecodePackage")
docPkg, err := godoc.DecodePackage(unit.Documentation.Source)
end()
if err != nil {
if errors.Is(err, godoc.ErrInvalidEncodingType) {
// Instead of returning a 500, return a 404 so the user can
// reprocess the documentation.
log.Errorf(ctx, "fetchMainDetails(%q, %q, %q): %v", um.Path, um.ModulePath, um.Version, err)
return nil, errUnitNotFoundWithoutFetch
}
return nil, err
}
docBody, docOutline, mobileOutline, err = getHTML(ctx, unit, docPkg)
// If err is ErrTooLarge, then docBody will have an appropriate message.
if err != nil && !errors.Is(err, dochtml.ErrTooLarge) {
return nil, err
}
end = middleware.ElapsedStat(ctx, "sourceFiles")
files = sourceFiles(unit, docPkg)
end()
}
return &MainDetails{
ExpandReadme: expandReadme,
NestedModules: nestedModules,
Subdirectories: subdirectories,
Licenses: transformLicenseMetadata(um.Licenses),
CommitTime: absoluteTime(um.CommitTime),
Readme: readme,
ReadmeOutline: readmeOutline,
DocOutline: docOutline,
DocBody: docBody,
SourceFiles: files,
RepositoryURL: um.SourceInfo.RepoURL(),
SourceURL: um.SourceInfo.DirectoryURL(internal.Suffix(um.Path, um.ModulePath)),
MobileOutline: mobileOutline,
NumImports: unit.NumImports,
ImportedByCount: importedByCount,
IsPackage: unit.IsPackage(),
}, nil
}
// moduleInfo extracts module info from a unit. This is a shim
// for functions ReadmeHTML and createDirectory that will be removed
// when we complete the switch to units.
func moduleInfo(um *internal.UnitMeta) *internal.ModuleInfo {
return &internal.ModuleInfo{
ModulePath: um.ModulePath,
Version: um.Version,
CommitTime: um.CommitTime,
IsRedistributable: um.IsRedistributable,
SourceInfo: um.SourceInfo,
}
}
// readmeContent renders the readme to html and collects the headings
// into an outline when the goldmark experiment active.
func readmeContent(ctx context.Context, u *internal.Unit) (_ safehtml.HTML, _ []*Heading, err error) {
defer derrors.Wrap(&err, "readmeContent(%q, %q, %q)", u.Path, u.ModulePath, u.Version)
defer middleware.ElapsedStat(ctx, "readmeContent")()
if !u.IsRedistributable {
return safehtml.HTML{}, nil, nil
}
mi := moduleInfo(&u.UnitMeta)
var (
readmeHTML safehtml.HTML
readmeOutline []*Heading
)
if experiment.IsActive(ctx, internal.ExperimentGoldmark) {
readmeHTML, readmeOutline, err = Readme(ctx, u)
} else {
readmeHTML, err = LegacyReadmeHTML(ctx, mi, u.Readme)
}
if err != nil {
return safehtml.HTML{}, nil, err
}
return readmeHTML, readmeOutline, nil
}
func getNestedModules(ctx context.Context, ds internal.DataSource, um *internal.UnitMeta) ([]*NestedModule, error) {
nestedModules, err := ds.GetNestedModules(ctx, um.ModulePath)
if err != nil {
return nil, err
}
var mods []*NestedModule
for _, m := range nestedModules {
if m.SeriesPath() == internal.SeriesPathForModule(um.ModulePath) {
continue
}
if !strings.HasPrefix(m.ModulePath, um.Path+"/") {
continue
}
mods = append(mods, &NestedModule{
URL: constructUnitURL(m.ModulePath, m.ModulePath, internal.LatestVersion),
Suffix: internal.Suffix(m.SeriesPath(), um.Path),
})
}
return mods, nil
}
func getSubdirectories(um *internal.UnitMeta, pkgs []*internal.PackageMeta) []*Subdirectory {
var sdirs []*Subdirectory
for _, pm := range pkgs {
if um.Path == pm.Path {
continue
}
sdirs = append(sdirs, &Subdirectory{
URL: constructUnitURL(pm.Path, um.ModulePath, linkVersion(um.Version, um.ModulePath)),
Suffix: internal.Suffix(pm.Path, um.Path),
Synopsis: pm.Synopsis,
})
}
sort.Slice(sdirs, func(i, j int) bool { return sdirs[i].Suffix < sdirs[j].Suffix })
return sdirs
}
func getHTML(ctx context.Context, u *internal.Unit, docPkg *godoc.Package) (body, outline, mobileOutline safehtml.HTML, err error) {
defer derrors.Wrap(&err, "getHTML(%s)", u.Path)
if experiment.IsActive(ctx, internal.ExperimentFrontendRenderDoc) && len(u.Documentation.Source) > 0 {
return renderDocParts(ctx, u, docPkg)
}
return godoc.ParseDoc(ctx, u.Documentation.HTML)
}
// getImportedByCount fetches the imported by count for the unit and returns a
// string to be displayed. If the datasource does not support imported by, it
// will return N/A.
func getImportedByCount(ctx context.Context, ds internal.DataSource, unit *internal.Unit) (_ string, err error) {
defer derrors.Wrap(&err, "getImportedByCount(%q, %q, %q)", unit.Path, unit.ModulePath, unit.Version)
defer middleware.ElapsedStat(ctx, "getImportedByCount")()
db, ok := ds.(*postgres.DB)
if !ok {
return "N/A", nil
}
// Get an exact number for a small limit, to determine whether we should
// fetch data from search_documents and display an approximate count, or
// just use the exact count.
importedBy, err := db.GetImportedBy(ctx, unit.Path, unit.ModulePath, mainPageImportedByLimit)
if err != nil {
return "", err
}
if len(importedBy) < mainPageImportedByLimit {
// Exact number is less than the limit, so just return that.
return strconv.Itoa(len(importedBy)), nil
}
// Exact number is greater than the limit, so fetch an approximate value
// from search_documents.num_imported_by. This number might be different
// than the result of GetImportedBy because alternative modules and internal
// packages are excluded.
var count int
if experiment.IsActive(ctx, internal.ExperimentGetUnitWithOneQuery) {
count = unit.NumImportedBy
} else {
count, err = db.GetImportedByCount(ctx, unit.Path, unit.ModulePath)
if err != nil {
if err == sql.ErrNoRows {
log.Errorf(ctx, "missing search_documents row for path %s, module path %s", unit.Path, unit.ModulePath)
return "", nil
}
return "", err
}
if count < mainPageImportedByLimit {
count = mainPageImportedByLimit
}
}
// Treat the result as approximate.
return fmt.Sprintf("%d+", approximateLowerBound(count)), nil
}
// approximateLowerBound rounds n down to a multiple of a power of 10.
// See the test for examples.
func approximateLowerBound(n int) int {
if n == 0 {
return 0
}
f := float64(n)
powerOf10 := math.Pow(10, math.Floor(math.Log10(f)))
return int(powerOf10 * math.Floor(f/powerOf10))
}