blob: 162a382b060f3edc75a41b6b70394c1f6df3843a [file] [log] [blame]
// Copyright 2011 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 present
import (
"bufio"
"bytes"
"errors"
"fmt"
"html/template"
"io"
"log"
"net/url"
"os"
"regexp"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/text"
)
var (
parsers = make(map[string]ParseFunc)
funcs = template.FuncMap{}
)
// Template returns an empty template with the action functions in its FuncMap.
func Template() *template.Template {
return template.New("").Funcs(funcs)
}
// Render renders the doc to the given writer using the provided template.
func (d *Doc) Render(w io.Writer, t *template.Template) error {
data := struct {
*Doc
Template *template.Template
PlayEnabled bool
NotesEnabled bool
}{d, t, PlayEnabled, NotesEnabled}
return t.ExecuteTemplate(w, "root", data)
}
// Render renders the section to the given writer using the provided template.
func (s *Section) Render(w io.Writer, t *template.Template) error {
data := struct {
*Section
Template *template.Template
PlayEnabled bool
}{s, t, PlayEnabled}
return t.ExecuteTemplate(w, "section", data)
}
type ParseFunc func(ctx *Context, fileName string, lineNumber int, inputLine string) (Elem, error)
// Register binds the named action, which does not begin with a period, to the
// specified parser to be invoked when the name, with a period, appears in the
// present input text.
func Register(name string, parser ParseFunc) {
if len(name) == 0 || name[0] == ';' {
panic("bad name in Register: " + name)
}
parsers["."+name] = parser
}
// Doc represents an entire document.
type Doc struct {
Title string
Subtitle string
Summary string
Time time.Time
Authors []Author
TitleNotes []string
Sections []Section
Tags []string
OldURL []string
}
// Author represents the person who wrote and/or is presenting the document.
type Author struct {
Elem []Elem
}
// TextElem returns the first text elements of the author details.
// This is used to display the author' name, job title, and company
// without the contact details.
func (p *Author) TextElem() (elems []Elem) {
for _, el := range p.Elem {
if _, ok := el.(Text); !ok {
break
}
elems = append(elems, el)
}
return
}
// Section represents a section of a document (such as a presentation slide)
// comprising a title and a list of elements.
type Section struct {
Number []int
Title string
ID string // HTML anchor ID
Elem []Elem
Notes []string
Classes []string
Styles []string
}
// HTMLAttributes for the section
func (s Section) HTMLAttributes() template.HTMLAttr {
if len(s.Classes) == 0 && len(s.Styles) == 0 {
return ""
}
var class string
if len(s.Classes) > 0 {
class = fmt.Sprintf(`class=%q`, strings.Join(s.Classes, " "))
}
var style string
if len(s.Styles) > 0 {
style = fmt.Sprintf(`style=%q`, strings.Join(s.Styles, " "))
}
return template.HTMLAttr(strings.Join([]string{class, style}, " "))
}
// Sections contained within the section.
func (s Section) Sections() (sections []Section) {
for _, e := range s.Elem {
if section, ok := e.(Section); ok {
sections = append(sections, section)
}
}
return
}
// Level returns the level of the given section.
// The document title is level 1, main section 2, etc.
func (s Section) Level() int {
return len(s.Number) + 1
}
// FormattedNumber returns a string containing the concatenation of the
// numbers identifying a Section.
func (s Section) FormattedNumber() string {
b := &bytes.Buffer{}
for _, n := range s.Number {
fmt.Fprintf(b, "%v.", n)
}
return b.String()
}
func (s Section) TemplateName() string { return "section" }
// Elem defines the interface for a present element. That is, something that
// can provide the name of the template used to render the element.
type Elem interface {
TemplateName() string
}
// renderElem implements the elem template function, used to render
// sub-templates.
func renderElem(t *template.Template, e Elem) (template.HTML, error) {
var data interface{} = e
if s, ok := e.(Section); ok {
data = struct {
Section
Template *template.Template
}{s, t}
}
return execTemplate(t, e.TemplateName(), data)
}
// pageNum derives a page number from a section.
func pageNum(s Section, offset int) int {
if len(s.Number) == 0 {
return offset
}
return s.Number[0] + offset
}
func init() {
funcs["elem"] = renderElem
funcs["pagenum"] = pageNum
}
// execTemplate is a helper to execute a template and return the output as a
// template.HTML value.
func execTemplate(t *template.Template, name string, data interface{}) (template.HTML, error) {
b := new(bytes.Buffer)
err := t.ExecuteTemplate(b, name, data)
if err != nil {
return "", err
}
return template.HTML(b.String()), nil
}
// Text represents an optionally preformatted paragraph.
type Text struct {
Lines []string
Pre bool
Raw string // original text, for Pre==true
}
func (t Text) TemplateName() string { return "text" }
// List represents a bulleted list.
type List struct {
Bullet []string
}
func (l List) TemplateName() string { return "list" }
// Lines is a helper for parsing line-based input.
type Lines struct {
line int // 0 indexed, so has 1-indexed number of last line returned
text []string
comment string
}
func readLines(r io.Reader) (*Lines, error) {
var lines []string
s := bufio.NewScanner(r)
for s.Scan() {
lines = append(lines, s.Text())
}
if err := s.Err(); err != nil {
return nil, err
}
return &Lines{0, lines, "#"}, nil
}
func (l *Lines) next() (text string, ok bool) {
for {
current := l.line
l.line++
if current >= len(l.text) {
return "", false
}
text = l.text[current]
// Lines starting with l.comment are comments.
if l.comment == "" || !strings.HasPrefix(text, l.comment) {
ok = true
break
}
}
return
}
func (l *Lines) back() {
l.line--
}
func (l *Lines) nextNonEmpty() (text string, ok bool) {
for {
text, ok = l.next()
if !ok {
return
}
if len(text) > 0 {
break
}
}
return
}
// A Context specifies the supporting context for parsing a presentation.
type Context struct {
// ReadFile reads the file named by filename and returns the contents.
ReadFile func(filename string) ([]byte, error)
}
// ParseMode represents flags for the Parse function.
type ParseMode int
const (
// If set, parse only the title and subtitle.
TitlesOnly ParseMode = 1
)
// Parse parses a document from r.
func (ctx *Context) Parse(r io.Reader, name string, mode ParseMode) (*Doc, error) {
doc := new(Doc)
lines, err := readLines(r)
if err != nil {
return nil, err
}
// Detect Markdown-enabled vs legacy present file.
// Markdown-enabled files have a title line beginning with "# "
// (like preprocessed C files of yore).
isMarkdown := false
for i := lines.line; i < len(lines.text); i++ {
line := lines.text[i]
if line == "" {
continue
}
isMarkdown = strings.HasPrefix(line, "# ")
break
}
sectionPrefix := "*"
if isMarkdown {
sectionPrefix = "##"
lines.comment = "//"
}
for i := lines.line; i < len(lines.text); i++ {
if strings.HasPrefix(lines.text[i], sectionPrefix) {
break
}
if isSpeakerNote(lines.text[i]) {
doc.TitleNotes = append(doc.TitleNotes, trimSpeakerNote(lines.text[i]))
}
}
err = parseHeader(doc, isMarkdown, lines)
if err != nil {
return nil, err
}
if mode&TitlesOnly != 0 {
return doc, nil
}
// Authors
if doc.Authors, err = parseAuthors(name, sectionPrefix, lines); err != nil {
return nil, err
}
// Sections
if doc.Sections, err = parseSections(ctx, name, sectionPrefix, lines, []int{}); err != nil {
return nil, err
}
return doc, nil
}
// Parse parses a document from r. Parse reads assets used by the presentation
// from the file system using os.ReadFile.
func Parse(r io.Reader, name string, mode ParseMode) (*Doc, error) {
ctx := Context{ReadFile: os.ReadFile}
return ctx.Parse(r, name, mode)
}
// isHeading matches any section heading.
var (
isHeadingLegacy = regexp.MustCompile(`^\*+( |$)`)
isHeadingMarkdown = regexp.MustCompile(`^\#+( |$)`)
)
// lesserHeading returns true if text is a heading of a lesser or equal level
// than that denoted by prefix.
func lesserHeading(isHeading *regexp.Regexp, text, prefix string) bool {
return isHeading.MatchString(text) && !strings.HasPrefix(text, prefix+prefix[:1])
}
// parseSections parses Sections from lines for the section level indicated by
// number (a nil number indicates the top level).
func parseSections(ctx *Context, name, prefix string, lines *Lines, number []int) ([]Section, error) {
isMarkdown := prefix[0] == '#'
isHeading := isHeadingLegacy
if isMarkdown {
isHeading = isHeadingMarkdown
}
var sections []Section
for i := 1; ; i++ {
// Next non-empty line is title.
text, ok := lines.nextNonEmpty()
for ok && text == "" {
text, ok = lines.next()
}
if !ok {
break
}
if text != prefix && !strings.HasPrefix(text, prefix+" ") {
lines.back()
break
}
// Markdown sections can end in {#id} to set the HTML anchor for the section.
// This is nicer than the default #TOC_1_2-style anchor.
title := strings.TrimSpace(text[len(prefix):])
id := ""
if isMarkdown && strings.HasSuffix(title, "}") {
j := strings.LastIndex(title, "{#")
if j >= 0 {
id = title[j+2 : len(title)-1]
title = strings.TrimSpace(title[:j])
}
}
section := Section{
Number: append(append([]int{}, number...), i),
Title: title,
ID: id,
}
text, ok = lines.nextNonEmpty()
for ok && !lesserHeading(isHeading, text, prefix) {
var e Elem
r, _ := utf8.DecodeRuneInString(text)
switch {
case !isMarkdown && unicode.IsSpace(r):
i := strings.IndexFunc(text, func(r rune) bool {
return !unicode.IsSpace(r)
})
if i < 0 {
break
}
indent := text[:i]
var s []string
for ok && (strings.HasPrefix(text, indent) || text == "") {
if text != "" {
text = text[i:]
}
s = append(s, text)
text, ok = lines.next()
}
lines.back()
pre := strings.Join(s, "\n")
raw := pre
pre = strings.Replace(pre, "\t", " ", -1) // browsers treat tabs badly
pre = strings.TrimRightFunc(pre, unicode.IsSpace)
e = Text{Lines: []string{pre}, Pre: true, Raw: raw}
case !isMarkdown && strings.HasPrefix(text, "- "):
var b []string
for {
if strings.HasPrefix(text, "- ") {
b = append(b, text[2:])
} else if len(b) > 0 && strings.HasPrefix(text, " ") {
b[len(b)-1] += "\n" + strings.TrimSpace(text)
} else {
break
}
if text, ok = lines.next(); !ok {
break
}
}
lines.back()
e = List{Bullet: b}
case isSpeakerNote(text):
section.Notes = append(section.Notes, trimSpeakerNote(text))
case strings.HasPrefix(text, prefix+prefix[:1]+" ") || text == prefix+prefix[:1]:
lines.back()
subsecs, err := parseSections(ctx, name, prefix+prefix[:1], lines, section.Number)
if err != nil {
return nil, err
}
for _, ss := range subsecs {
section.Elem = append(section.Elem, ss)
}
case strings.HasPrefix(text, prefix+prefix[:1]):
return nil, fmt.Errorf("%s:%d: badly nested section inside %s: %s", name, lines.line, prefix, text)
case strings.HasPrefix(text, "."):
args := strings.Fields(text)
if args[0] == ".background" {
section.Classes = append(section.Classes, "background")
section.Styles = append(section.Styles, "background-image: url('"+args[1]+"')")
break
}
parser := parsers[args[0]]
if parser == nil {
return nil, fmt.Errorf("%s:%d: unknown command %q", name, lines.line, text)
}
t, err := parser(ctx, name, lines.line, text)
if err != nil {
return nil, err
}
e = t
case isMarkdown:
// Collect Markdown lines, including blank lines and indented text.
var block []string
endLine, endBlock := lines.line-1, -1 // end is last non-empty line
for ok {
trim := strings.TrimSpace(text)
if trim != "" {
// Command breaks text block.
// Section heading breaks text block in markdown.
if text[0] == '.' || text[0] == '#' || isSpeakerNote(text) {
break
}
if strings.HasPrefix(text, `\.`) { // Backslash escapes initial period.
text = text[1:]
}
endLine, endBlock = lines.line, len(block)
}
block = append(block, text)
text, ok = lines.next()
}
block = block[:endBlock+1]
lines.line = endLine + 1
if len(block) == 0 {
break
}
// Replace all leading tabs with 4 spaces,
// which render better in code blocks.
// CommonMark defines that for parsing the structure of the file
// a tab is equivalent to 4 spaces, so this change won't
// affect the later parsing at all.
// An alternative would be to apply this to code blocks after parsing,
// at the same time that we update <a> targets, but that turns out
// to be quite difficult to modify in the AST.
for i, line := range block {
if len(line) > 0 && line[0] == '\t' {
short := strings.TrimLeft(line, "\t")
line = strings.Repeat(" ", len(line)-len(short)) + short
block[i] = line
}
}
html, err := renderMarkdown([]byte(strings.Join(block, "\n")))
if err != nil {
return nil, err
}
e = HTML{HTML: html}
default:
// Collect text lines.
var block []string
for ok && strings.TrimSpace(text) != "" {
// Command breaks text block.
// Section heading breaks text block in markdown.
if text[0] == '.' || isSpeakerNote(text) {
lines.back()
break
}
if strings.HasPrefix(text, `\.`) { // Backslash escapes initial period.
text = text[1:]
}
block = append(block, text)
text, ok = lines.next()
}
if len(block) == 0 {
break
}
e = Text{Lines: block}
}
if e != nil {
section.Elem = append(section.Elem, e)
}
text, ok = lines.nextNonEmpty()
}
if isHeading.MatchString(text) {
lines.back()
}
sections = append(sections, section)
}
if len(sections) == 0 {
return nil, fmt.Errorf("%s:%d: unexpected line: %s", name, lines.line+1, lines.text[lines.line])
}
return sections, nil
}
func parseHeader(doc *Doc, isMarkdown bool, lines *Lines) error {
var ok bool
// First non-empty line starts header.
doc.Title, ok = lines.nextNonEmpty()
if !ok {
return errors.New("unexpected EOF; expected title")
}
if isMarkdown {
doc.Title = strings.TrimSpace(strings.TrimPrefix(doc.Title, "#"))
}
for {
text, ok := lines.next()
if !ok {
return errors.New("unexpected EOF")
}
if text == "" {
break
}
if isSpeakerNote(text) {
continue
}
if strings.HasPrefix(text, "Tags:") {
tags := strings.Split(text[len("Tags:"):], ",")
for i := range tags {
tags[i] = strings.TrimSpace(tags[i])
}
doc.Tags = append(doc.Tags, tags...)
} else if strings.HasPrefix(text, "Summary:") {
doc.Summary = strings.TrimSpace(text[len("Summary:"):])
} else if strings.HasPrefix(text, "OldURL:") {
doc.OldURL = append(doc.OldURL, strings.TrimSpace(text[len("OldURL:"):]))
} else if t, ok := parseTime(text); ok {
doc.Time = t
} else if doc.Subtitle == "" {
doc.Subtitle = text
} else {
return fmt.Errorf("unexpected header line: %q", text)
}
}
return nil
}
func parseAuthors(name, sectionPrefix string, lines *Lines) (authors []Author, err error) {
// This grammar demarcates authors with blanks.
// Skip blank lines.
if _, ok := lines.nextNonEmpty(); !ok {
return nil, errors.New("unexpected EOF")
}
lines.back()
var a *Author
for {
text, ok := lines.next()
if !ok {
return nil, errors.New("unexpected EOF")
}
// If we find a section heading, we're done.
if strings.HasPrefix(text, sectionPrefix) {
lines.back()
break
}
if isSpeakerNote(text) {
continue
}
// If we encounter a blank we're done with this author.
if a != nil && len(text) == 0 {
authors = append(authors, *a)
a = nil
continue
}
if a == nil {
a = new(Author)
}
// Parse the line. Those that
// - begin with @ are twitter names,
// - contain slashes are links, or
// - contain an @ symbol are an email address.
// The rest is just text.
var el Elem
switch {
case strings.HasPrefix(text, "@"):
el = parseAuthorURL(name, "http://twitter.com/"+text[1:])
case strings.Contains(text, ":"):
el = parseAuthorURL(name, text)
case strings.Contains(text, "@"):
el = parseAuthorURL(name, "mailto:"+text)
}
if l, ok := el.(Link); ok {
l.Label = text
el = l
}
if el == nil {
el = Text{Lines: []string{text}}
}
a.Elem = append(a.Elem, el)
}
if a != nil {
authors = append(authors, *a)
}
return authors, nil
}
func parseAuthorURL(name, text string) Elem {
u, err := url.Parse(text)
if err != nil {
log.Printf("parsing %s author block: invalid URL %q: %v", name, text, err)
return nil
}
return Link{URL: u}
}
func parseTime(text string) (t time.Time, ok bool) {
t, err := time.Parse("15:04 2 Jan 2006", text)
if err == nil {
return t, true
}
t, err = time.Parse("2 Jan 2006", text)
if err == nil {
// at 11am UTC it is the same date everywhere
t = t.Add(time.Hour * 11)
return t, true
}
return time.Time{}, false
}
func isSpeakerNote(s string) bool {
return strings.HasPrefix(s, ": ") || s == ":"
}
func trimSpeakerNote(s string) string {
if s == ":" {
return ""
}
return strings.TrimPrefix(s, ": ")
}
func renderMarkdown(input []byte) (template.HTML, error) {
md := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe()))
reader := text.NewReader(input)
doc := md.Parser().Parse(reader)
fixupMarkdown(doc)
var b strings.Builder
if err := md.Renderer().Render(&b, input, doc); err != nil {
return "", err
}
return template.HTML(b.String()), nil
}
func fixupMarkdown(n ast.Node) {
ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
switch n := n.(type) {
case *ast.Link:
n.SetAttributeString("target", []byte("_blank"))
// https://developers.google.com/web/tools/lighthouse/audits/noopener
n.SetAttributeString("rel", []byte("noopener"))
}
}
return ast.WalkContinue, nil
})
}