1 // Copyright 2011 The Go Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
23 "github.com/yuin/goldmark"
24 "github.com/yuin/goldmark/ast"
25 "github.com/yuin/goldmark/renderer/html"
26 "github.com/yuin/goldmark/text"
30 parsers = make(map[string]ParseFunc)
31 funcs = template.FuncMap{}
34 // Template returns an empty template with the action functions in its FuncMap.
35 func Template() *template.Template {
36 return template.New("").Funcs(funcs)
39 // Render renders the doc to the given writer using the provided template.
40 func (d *Doc) Render(w io.Writer, t *template.Template) error {
43 Template *template.Template
46 }{d, t, PlayEnabled, NotesEnabled}
47 return t.ExecuteTemplate(w, "root", data)
50 // Render renders the section to the given writer using the provided template.
51 func (s *Section) Render(w io.Writer, t *template.Template) error {
54 Template *template.Template
57 return t.ExecuteTemplate(w, "section", data)
60 type ParseFunc func(ctx *Context, fileName string, lineNumber int, inputLine string) (Elem, error)
62 // Register binds the named action, which does not begin with a period, to the
63 // specified parser to be invoked when the name, with a period, appears in the
64 // present input text.
65 func Register(name string, parser ParseFunc) {
66 if len(name) == 0 || name[0] == ';' {
67 panic("bad name in Register: " + name)
69 parsers["."+name] = parser
72 // Doc represents an entire document.
85 // Author represents the person who wrote and/or is presenting the document.
90 // TextElem returns the first text elements of the author details.
91 // This is used to display the author' name, job title, and company
92 // without the contact details.
93 func (p *Author) TextElem() (elems []Elem) {
94 for _, el := range p.Elem {
95 if _, ok := el.(Text); !ok {
98 elems = append(elems, el)
103 // Section represents a section of a document (such as a presentation slide)
104 // comprising a title and a list of elements.
105 type Section struct {
108 ID string // HTML anchor ID
115 // HTMLAttributes for the section
116 func (s Section) HTMLAttributes() template.HTMLAttr {
117 if len(s.Classes) == 0 && len(s.Styles) == 0 {
122 if len(s.Classes) > 0 {
123 class = fmt.Sprintf(`class=%q`, strings.Join(s.Classes, " "))
126 if len(s.Styles) > 0 {
127 style = fmt.Sprintf(`style=%q`, strings.Join(s.Styles, " "))
129 return template.HTMLAttr(strings.Join([]string{class, style}, " "))
132 // Sections contained within the section.
133 func (s Section) Sections() (sections []Section) {
134 for _, e := range s.Elem {
135 if section, ok := e.(Section); ok {
136 sections = append(sections, section)
142 // Level returns the level of the given section.
143 // The document title is level 1, main section 2, etc.
144 func (s Section) Level() int {
145 return len(s.Number) + 1
148 // FormattedNumber returns a string containing the concatenation of the
149 // numbers identifying a Section.
150 func (s Section) FormattedNumber() string {
152 for _, n := range s.Number {
153 fmt.Fprintf(b, "%v.", n)
158 func (s Section) TemplateName() string { return "section" }
160 // Elem defines the interface for a present element. That is, something that
161 // can provide the name of the template used to render the element.
162 type Elem interface {
163 TemplateName() string
166 // renderElem implements the elem template function, used to render
168 func renderElem(t *template.Template, e Elem) (template.HTML, error) {
169 var data interface{} = e
170 if s, ok := e.(Section); ok {
173 Template *template.Template
176 return execTemplate(t, e.TemplateName(), data)
179 // pageNum derives a page number from a section.
180 func pageNum(s Section, offset int) int {
181 if len(s.Number) == 0 {
184 return s.Number[0] + offset
188 funcs["elem"] = renderElem
189 funcs["pagenum"] = pageNum
192 // execTemplate is a helper to execute a template and return the output as a
193 // template.HTML value.
194 func execTemplate(t *template.Template, name string, data interface{}) (template.HTML, error) {
195 b := new(bytes.Buffer)
196 err := t.ExecuteTemplate(b, name, data)
200 return template.HTML(b.String()), nil
203 // Text represents an optionally preformatted paragraph.
207 Raw string // original text, for Pre==true
210 func (t Text) TemplateName() string { return "text" }
212 // List represents a bulleted list.
217 func (l List) TemplateName() string { return "list" }
219 // Lines is a helper for parsing line-based input.
221 line int // 0 indexed, so has 1-indexed number of last line returned
226 func readLines(r io.Reader) (*Lines, error) {
228 s := bufio.NewScanner(r)
230 lines = append(lines, s.Text())
232 if err := s.Err(); err != nil {
235 return &Lines{0, lines, "#"}, nil
238 func (l *Lines) next() (text string, ok bool) {
242 if current >= len(l.text) {
245 text = l.text[current]
246 // Lines starting with l.comment are comments.
247 if l.comment == "" || !strings.HasPrefix(text, l.comment) {
255 func (l *Lines) back() {
259 func (l *Lines) nextNonEmpty() (text string, ok bool) {
272 // A Context specifies the supporting context for parsing a presentation.
273 type Context struct {
274 // ReadFile reads the file named by filename and returns the contents.
275 ReadFile func(filename string) ([]byte, error)
278 // ParseMode represents flags for the Parse function.
282 // If set, parse only the title and subtitle.
283 TitlesOnly ParseMode = 1
286 // Parse parses a document from r.
287 func (ctx *Context) Parse(r io.Reader, name string, mode ParseMode) (*Doc, error) {
289 lines, err := readLines(r)
294 // Detect Markdown-enabled vs legacy present file.
295 // Markdown-enabled files have a title line beginning with "# "
296 // (like preprocessed C files of yore).
298 for i := lines.line; i < len(lines.text); i++ {
299 line := lines.text[i]
303 isMarkdown = strings.HasPrefix(line, "# ")
313 for i := lines.line; i < len(lines.text); i++ {
314 if strings.HasPrefix(lines.text[i], sectionPrefix) {
318 if isSpeakerNote(lines.text[i]) {
319 doc.TitleNotes = append(doc.TitleNotes, trimSpeakerNote(lines.text[i]))
323 err = parseHeader(doc, isMarkdown, lines)
327 if mode&TitlesOnly != 0 {
332 if doc.Authors, err = parseAuthors(name, sectionPrefix, lines); err != nil {
337 if doc.Sections, err = parseSections(ctx, name, sectionPrefix, lines, []int{}); err != nil {
344 // Parse parses a document from r. Parse reads assets used by the presentation
345 // from the file system using ioutil.ReadFile.
346 func Parse(r io.Reader, name string, mode ParseMode) (*Doc, error) {
347 ctx := Context{ReadFile: ioutil.ReadFile}
348 return ctx.Parse(r, name, mode)
351 // isHeading matches any section heading.
353 isHeadingLegacy = regexp.MustCompile(`^\*+( |$)`)
354 isHeadingMarkdown = regexp.MustCompile(`^\#+( |$)`)
357 // lesserHeading returns true if text is a heading of a lesser or equal level
358 // than that denoted by prefix.
359 func lesserHeading(isHeading *regexp.Regexp, text, prefix string) bool {
360 return isHeading.MatchString(text) && !strings.HasPrefix(text, prefix+prefix[:1])
363 // parseSections parses Sections from lines for the section level indicated by
364 // number (a nil number indicates the top level).
365 func parseSections(ctx *Context, name, prefix string, lines *Lines, number []int) ([]Section, error) {
366 isMarkdown := prefix[0] == '#'
367 isHeading := isHeadingLegacy
369 isHeading = isHeadingMarkdown
371 var sections []Section
373 // Next non-empty line is title.
374 text, ok := lines.nextNonEmpty()
375 for ok && text == "" {
376 text, ok = lines.next()
381 if text != prefix && !strings.HasPrefix(text, prefix+" ") {
385 // Markdown sections can end in {#id} to set the HTML anchor for the section.
386 // This is nicer than the default #TOC_1_2-style anchor.
387 title := strings.TrimSpace(text[len(prefix):])
389 if isMarkdown && strings.HasSuffix(title, "}") {
390 j := strings.LastIndex(title, "{#")
392 id = title[j+2 : len(title)-1]
393 title = strings.TrimSpace(title[:j])
397 Number: append(append([]int{}, number...), i),
401 text, ok = lines.nextNonEmpty()
402 for ok && !lesserHeading(isHeading, text, prefix) {
404 r, _ := utf8.DecodeRuneInString(text)
406 case !isMarkdown && unicode.IsSpace(r):
407 i := strings.IndexFunc(text, func(r rune) bool {
408 return !unicode.IsSpace(r)
415 for ok && (strings.HasPrefix(text, indent) || text == "") {
420 text, ok = lines.next()
423 pre := strings.Join(s, "\n")
425 pre = strings.Replace(pre, "\t", " ", -1) // browsers treat tabs badly
426 pre = strings.TrimRightFunc(pre, unicode.IsSpace)
427 e = Text{Lines: []string{pre}, Pre: true, Raw: raw}
428 case !isMarkdown && strings.HasPrefix(text, "- "):
431 if strings.HasPrefix(text, "- ") {
432 b = append(b, text[2:])
433 } else if len(b) > 0 && strings.HasPrefix(text, " ") {
434 b[len(b)-1] += "\n" + strings.TrimSpace(text)
438 if text, ok = lines.next(); !ok {
444 case isSpeakerNote(text):
445 section.Notes = append(section.Notes, trimSpeakerNote(text))
446 case strings.HasPrefix(text, prefix+prefix[:1]+" ") || text == prefix+prefix[:1]:
448 subsecs, err := parseSections(ctx, name, prefix+prefix[:1], lines, section.Number)
452 for _, ss := range subsecs {
453 section.Elem = append(section.Elem, ss)
455 case strings.HasPrefix(text, prefix+prefix[:1]):
456 return nil, fmt.Errorf("%s:%d: badly nested section inside %s: %s", name, lines.line, prefix, text)
457 case strings.HasPrefix(text, "."):
458 args := strings.Fields(text)
459 if args[0] == ".background" {
460 section.Classes = append(section.Classes, "background")
461 section.Styles = append(section.Styles, "background-image: url('"+args[1]+"')")
464 parser := parsers[args[0]]
466 return nil, fmt.Errorf("%s:%d: unknown command %q", name, lines.line, text)
468 t, err := parser(ctx, name, lines.line, text)
475 // Collect Markdown lines, including blank lines and indented text.
477 endLine, endBlock := lines.line-1, -1 // end is last non-empty line
479 trim := strings.TrimSpace(text)
481 // Command breaks text block.
482 // Section heading breaks text block in markdown.
483 if text[0] == '.' || text[0] == '#' || isSpeakerNote(text) {
486 if strings.HasPrefix(text, `\.`) { // Backslash escapes initial period.
489 endLine, endBlock = lines.line, len(block)
491 block = append(block, text)
492 text, ok = lines.next()
494 block = block[:endBlock+1]
495 lines.line = endLine + 1
500 // Replace all leading tabs with 4 spaces,
501 // which render better in code blocks.
502 // CommonMark defines that for parsing the structure of the file
503 // a tab is equivalent to 4 spaces, so this change won't
504 // affect the later parsing at all.
505 // An alternative would be to apply this to code blocks after parsing,
506 // at the same time that we update <a> targets, but that turns out
507 // to be quite difficult to modify in the AST.
508 for i, line := range block {
509 if len(line) > 0 && line[0] == '\t' {
510 short := strings.TrimLeft(line, "\t")
511 line = strings.Repeat(" ", len(line)-len(short)) + short
515 html, err := renderMarkdown([]byte(strings.Join(block, "\n")))
522 // Collect text lines.
524 for ok && strings.TrimSpace(text) != "" {
525 // Command breaks text block.
526 // Section heading breaks text block in markdown.
527 if text[0] == '.' || isSpeakerNote(text) {
530 if strings.HasPrefix(text, `\.`) { // Backslash escapes initial period.
533 block = append(block, text)
534 text, ok = lines.next()
539 e = Text{Lines: block}
542 section.Elem = append(section.Elem, e)
544 text, ok = lines.nextNonEmpty()
546 if isHeading.MatchString(text) {
549 sections = append(sections, section)
552 if len(sections) == 0 {
553 return nil, fmt.Errorf("%s:%d: unexpected line: %s", name, lines.line+1, lines.text[lines.line])
558 func parseHeader(doc *Doc, isMarkdown bool, lines *Lines) error {
560 // First non-empty line starts header.
561 doc.Title, ok = lines.nextNonEmpty()
563 return errors.New("unexpected EOF; expected title")
566 doc.Title = strings.TrimSpace(strings.TrimPrefix(doc.Title, "#"))
570 text, ok := lines.next()
572 return errors.New("unexpected EOF")
577 if isSpeakerNote(text) {
580 if strings.HasPrefix(text, "Tags:") {
581 tags := strings.Split(text[len("Tags:"):], ",")
582 for i := range tags {
583 tags[i] = strings.TrimSpace(tags[i])
585 doc.Tags = append(doc.Tags, tags...)
586 } else if strings.HasPrefix(text, "Summary:") {
587 doc.Summary = strings.TrimSpace(text[len("Summary:"):])
588 } else if strings.HasPrefix(text, "OldURL:") {
589 doc.OldURL = append(doc.OldURL, strings.TrimSpace(text[len("OldURL:"):]))
590 } else if t, ok := parseTime(text); ok {
592 } else if doc.Subtitle == "" {
595 return fmt.Errorf("unexpected header line: %q", text)
601 func parseAuthors(name, sectionPrefix string, lines *Lines) (authors []Author, err error) {
602 // This grammar demarcates authors with blanks.
605 if _, ok := lines.nextNonEmpty(); !ok {
606 return nil, errors.New("unexpected EOF")
612 text, ok := lines.next()
614 return nil, errors.New("unexpected EOF")
617 // If we find a section heading, we're done.
618 if strings.HasPrefix(text, sectionPrefix) {
623 if isSpeakerNote(text) {
627 // If we encounter a blank we're done with this author.
628 if a != nil && len(text) == 0 {
629 authors = append(authors, *a)
637 // Parse the line. Those that
638 // - begin with @ are twitter names,
639 // - contain slashes are links, or
640 // - contain an @ symbol are an email address.
641 // The rest is just text.
644 case strings.HasPrefix(text, "@"):
645 el = parseAuthorURL(name, "http://twitter.com/"+text[1:])
646 case strings.Contains(text, ":"):
647 el = parseAuthorURL(name, text)
648 case strings.Contains(text, "@"):
649 el = parseAuthorURL(name, "mailto:"+text)
651 if l, ok := el.(Link); ok {
656 el = Text{Lines: []string{text}}
658 a.Elem = append(a.Elem, el)
661 authors = append(authors, *a)
666 func parseAuthorURL(name, text string) Elem {
667 u, err := url.Parse(text)
669 log.Printf("parsing %s author block: invalid URL %q: %v", name, text, err)
675 func parseTime(text string) (t time.Time, ok bool) {
676 t, err := time.Parse("15:04 2 Jan 2006", text)
680 t, err = time.Parse("2 Jan 2006", text)
682 // at 11am UTC it is the same date everywhere
683 t = t.Add(time.Hour * 11)
686 return time.Time{}, false
689 func isSpeakerNote(s string) bool {
690 return strings.HasPrefix(s, ": ") || s == ":"
693 func trimSpeakerNote(s string) string {
697 return strings.TrimPrefix(s, ": ")
700 func renderMarkdown(input []byte) (template.HTML, error) {
701 md := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe()))
702 reader := text.NewReader(input)
703 doc := md.Parser().Parse(reader)
705 var b strings.Builder
706 if err := md.Renderer().Render(&b, input, doc); err != nil {
709 return template.HTML(b.String()), nil
712 func fixupMarkdown(n ast.Node) {
713 ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
715 switch n := n.(type) {
717 n.SetAttributeString("target", []byte("_blank"))
718 // https://developers.google.com/web/tools/lighthouse/audits/noopener
719 n.SetAttributeString("rel", []byte("noopener"))
722 return ast.WalkContinue, nil