blob: 3da121818125dc7b60f184bde094cd4de4808ecc [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"
"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"
"rsc.io/markdown"
)
// 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
}
p := markdown.Parser{
HeadingIDs: true,
Strikethrough: true,
TaskListItems: true,
AutoLinkText: true,
AutoLinkAssumeHTTP: true,
Table: true,
Emoji: true,
}
doc := p.Parse(string(source))
addMissingHeadingIDs(doc) // extractTOC is going to use these ids, so do this first
et := &extractTOC{ctx: ctx}
et.extract(doc)
renderCodeBlocks(doc)
doc.PrintHTML(&buf)
id := strings.TrimSuffix(filepath.Base(filename), ".md")
return &StyleSection{
ID: id,
Title: camelCase(id),
Content: uncheckedconversions.HTMLFromStringKnownToSatisfyTypeContract(buf.String()),
Outline: et.Headings,
}, nil
}
func writeLines(w *bytes.Buffer, lines []string) {
for _, l := range lines {
w.WriteString(l)
w.WriteString("\n")
}
}
func writeEscapedLines(w *bytes.Buffer, lines []string) {
for _, l := range lines {
w.WriteString(html.EscapeString(l))
w.WriteString("\n")
}
}
// renderFencedCodeBlock writes html code snippets twice, once as actual
// html for the page and again as a code snippet.
func renderCodeBlocks(doc *markdown.Document) {
var rewriteBlocks func([]markdown.Block)
rewriteBlocks = func(blocks []markdown.Block) {
for i, b := range blocks {
switch x := b.(type) {
case *markdown.Text:
case *markdown.HTMLBlock:
case *markdown.Table:
case *markdown.Empty:
case *markdown.ThematicBreak:
case *markdown.Paragraph:
rewriteBlocks([]markdown.Block{x.Text})
case *markdown.List:
rewriteBlocks(x.Items)
case *markdown.Item:
rewriteBlocks(x.Blocks)
case *markdown.Quote:
rewriteBlocks(x.Blocks)
case *markdown.Heading:
case *markdown.CodeBlock:
htmltag := &markdown.HTMLBlock{}
var buf bytes.Buffer
buf.WriteString("<span>\n")
writeLines(&buf, x.Text)
buf.WriteString("</span>\n")
buf.WriteString("<pre class=\"StringifyElement-markup js-clipboard\">\n")
writeEscapedLines(&buf, x.Text)
buf.WriteString("</pre>")
htmltag.Text = append(htmltag.Text, buf.String())
blocks[i] = htmltag
}
}
}
rewriteBlocks(doc.Blocks)
}
func addMissingHeadingIDs(doc *markdown.Document) {
walkBlocks(doc.Blocks, func(b markdown.Block) error {
if heading, ok := b.(*markdown.Heading); ok {
if heading.ID != "" {
return nil
}
var buf bytes.Buffer
for _, inl := range heading.Text.Inline {
inl.PrintText(&buf)
}
f := func(c rune) bool {
return !('a' <= c && c <= 'z') && !('A' <= c && c <= 'Z') && !('0' <= c && c <= '9')
}
heading.ID = strings.ToLower(strings.Join(strings.FieldsFunc(buf.String(), f), "-"))
}
return nil
})
}
// 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, "")
}