blob: 38d9619dc16c4e76b3b220f8c21ff50906682c21 [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 frontend
import (
"bytes"
"context"
"html"
"io/fs"
"net/http"
"path"
"path/filepath"
"strings"
"github.com/google/safehtml"
"github.com/google/safehtml/uncheckedconversions"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
ghtml "github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/util"
"golang.org/x/pkgsite/internal"
"golang.org/x/pkgsite/internal/derrors"
"golang.org/x/pkgsite/internal/experiment"
"golang.org/x/pkgsite/internal/frontend/page"
"golang.org/x/pkgsite/internal/frontend/serrors"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// serveStyleGuide serves the styleguide page, the content of which is
// generated from the markdown files in static/shared.
func (s *Server) serveStyleGuide(w http.ResponseWriter, r *http.Request, ds internal.DataSource) error {
ctx := r.Context()
if !experiment.IsActive(ctx, internal.ExperimentStyleGuide) {
return &serrors.ServerError{Status: http.StatusNotFound}
}
page, err := styleGuide(ctx, s.staticFS)
page.BasePage = s.newBasePage(r, "Style Guide")
page.AllowWideContent = true
page.UseResponsiveLayout = true
page.Title = "Style Guide"
if err != nil {
return err
}
s.servePage(ctx, w, "styleguide", page)
return nil
}
type styleGuidePage struct {
page.BasePage
Title string
Sections []*StyleSection
Outline []*Heading
}
// styleGuide collects the paths to the markdown files in staticFS,
// renders them into sections for the styleguide, and merges the document
// outlines into a single page outline.
func styleGuide(ctx context.Context, staticFS fs.FS) (_ *styleGuidePage, err error) {
defer derrors.WrapStack(&err, "styleGuide)")
files, err := markdownFiles(staticFS)
if err != nil {
return nil, err
}
var sections []*StyleSection
for _, f := range files {
doc, err := styleSection(ctx, staticFS, f)
if err != nil {
return nil, err
}
sections = append(sections, doc)
}
var outline []*Heading
for _, s := range sections {
outline = append(outline, s.Outline...)
}
return &styleGuidePage{
Sections: sections,
Outline: outline,
}, nil
}
// StyleSection represents a section on the styleguide page.
type StyleSection struct {
// ID is the ID for the header element of the section.
ID string
// Title is the title of the section, taken from the name
// of the markdown file.
Title string
// Content is the HTML rendered from the parsed markdown file.
Content safehtml.HTML
// Outline is a collection of headings used in the navigation.
Outline []*Heading
}
// styleSection uses goldmark to parse a markdown file and render
// a section of the styleguide.
func styleSection(ctx context.Context, fsys fs.FS, filename string) (_ *StyleSection, err error) {
defer derrors.WrapStack(&err, "styleSection(%q)", filename)
var buf bytes.Buffer
source, err := fs.ReadFile(fsys, filename)
if err != nil {
return nil, err
}
// We set priority values so that we always use our custom transformer
// instead of the default ones. The default values are in:
// https://github.com/yuin/goldmark/blob/7b90f04af43131db79ec320be0bd4744079b346f/parser/parser.go#L567
const (
astTransformerPriority = 10000
nodeRenderersPriority = 100
)
et := &extractTOC{ctx: ctx}
md := goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
parser.WithAttribute(),
parser.WithASTTransformers(
util.Prioritized(et, astTransformerPriority),
),
),
goldmark.WithRendererOptions(
renderer.WithNodeRenderers(
util.Prioritized(&guideRenderer{}, nodeRenderersPriority),
),
ghtml.WithUnsafe(),
ghtml.WithXHTML(),
),
)
if err := md.Convert(source, &buf); err != nil {
return nil, err
}
id := strings.TrimSuffix(filepath.Base(filename), ".md")
return &StyleSection{
ID: id,
Title: camelCase(id),
Content: uncheckedconversions.HTMLFromStringKnownToSatisfyTypeContract(buf.String()),
Outline: et.Headings,
}, nil
}
// guideRenderer is a renderer.NodeRenderer implementation that renders
// styleguide sections.
type guideRenderer struct {
ghtml.Config
}
func (r *guideRenderer) writeLines(w util.BufWriter, source []byte, n ast.Node) {
l := n.Lines().Len()
for i := 0; i < l; i++ {
line := n.Lines().At(i)
w.Write(line.Value(source))
}
}
func (r *guideRenderer) writeEscapedLines(w util.BufWriter, source []byte, n ast.Node) {
l := n.Lines().Len()
for i := 0; i < l; i++ {
line := n.Lines().At(i)
w.Write([]byte(html.EscapeString(string(line.Value(source)))))
}
}
// renderFencedCodeBlock writes html code snippets twice, once as actual
// html for the page and again as a code snippet.
func (r *guideRenderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.FencedCodeBlock)
w.WriteString("<span>\n")
r.writeLines(w, source, n)
w.WriteString("</span>\n")
w.WriteString("<pre class=\"StringifyElement-markup js-clipboard\">\n")
r.writeEscapedLines(w, source, n)
w.WriteString("</pre>\n")
return ast.WalkContinue, nil
}
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
func (r *guideRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
}
// markdownFiles walks the shared directory of fsys and collects
// the paths to markdown files.
func markdownFiles(fsys fs.FS) ([]string, error) {
var matches []string
err := fs.WalkDir(fsys, "shared", func(filepath string, _ fs.DirEntry, err error) error {
if err != nil {
return err
}
if path.Ext(filepath) == ".md" {
matches = append(matches, filepath)
}
return nil
})
if err != nil {
return nil, err
}
return matches, nil
}
// camelCase turns a snake cased string into a camel case string.
// For example, hello-world becomes HelloWorld. This function is
// used to ensure proper casing in the classnames of the style
// sections.
func camelCase(s string) string {
p := strings.Split(s, "-")
var o []string
for _, v := range p {
o = append(o, cases.Title(language.Und).String(v))
}
return strings.Join(o, "")
}