// 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" "io/ioutil" "log" "net/url" "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 ioutil.ReadFile. func Parse(r io.Reader, name string, mode ParseMode) (*Doc, error) { ctx := Context{ReadFile: ioutil.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 default: var block []string for ok && strings.TrimSpace(text) != "" { // Command breaks text block. // Section heading breaks text block in markdown. if text[0] == '.' || isMarkdown && 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 } if isMarkdown { // 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 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} } else { 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 }) }