html.go (view raw)
1//
2// Blackfriday Markdown Processor
3// Available at http://github.com/russross/blackfriday
4//
5// Copyright © 2011 Russ Ross <russ@russross.com>.
6// Distributed under the Simplified BSD License.
7// See README.md for details.
8//
9
10//
11//
12// HTML rendering backend
13//
14//
15
16package blackfriday
17
18import (
19 "bytes"
20 "fmt"
21 "regexp"
22 "strconv"
23 "strings"
24)
25
26// Html renderer configuration options.
27const (
28 HTML_SKIP_HTML = 1 << iota // skip preformatted HTML blocks
29 HTML_SKIP_STYLE // skip embedded <style> elements
30 HTML_SKIP_IMAGES // skip embedded images
31 HTML_SKIP_LINKS // skip all links
32 HTML_SANITIZE_OUTPUT // strip output of everything that's not known to be safe
33 HTML_SAFELINK // only link to trusted protocols
34 HTML_NOFOLLOW_LINKS // only link with rel="nofollow"
35 HTML_TOC // generate a table of contents
36 HTML_OMIT_CONTENTS // skip the main contents (for a standalone table of contents)
37 HTML_COMPLETE_PAGE // generate a complete HTML page
38 HTML_GITHUB_BLOCKCODE // use github fenced code rendering rules
39 HTML_USE_XHTML // generate XHTML output instead of HTML
40 HTML_USE_SMARTYPANTS // enable smart punctuation substitutions
41 HTML_SMARTYPANTS_FRACTIONS // enable smart fractions (with HTML_USE_SMARTYPANTS)
42 HTML_SMARTYPANTS_LATEX_DASHES // enable LaTeX-style dashes (with HTML_USE_SMARTYPANTS)
43)
44
45var (
46 alignments = []string{
47 "left",
48 "right",
49 "center",
50 }
51
52 // TODO: improve this regexp to catch all possible entities:
53 htmlEntity = regexp.MustCompile(`&[a-z]{2,5};`)
54)
55
56// Html is a type that implements the Renderer interface for HTML output.
57//
58// Do not create this directly, instead use the HtmlRenderer function.
59type Html struct {
60 flags int // HTML_* options
61 closeTag string // how to end singleton tags: either " />\n" or ">\n"
62 title string // document title
63 css string // optional css file url (used with HTML_COMPLETE_PAGE)
64
65 // table of contents data
66 tocMarker int
67 headerCount int
68 currentLevel int
69 toc *bytes.Buffer
70
71 smartypants *smartypantsRenderer
72}
73
74const (
75 xhtmlClose = " />\n"
76 htmlClose = ">\n"
77)
78
79// HtmlRenderer creates and configures an Html object, which
80// satisfies the Renderer interface.
81//
82// flags is a set of HTML_* options ORed together.
83// title is the title of the document, and css is a URL for the document's
84// stylesheet.
85// title and css are only used when HTML_COMPLETE_PAGE is selected.
86func HtmlRenderer(flags int, title string, css string) Renderer {
87 // configure the rendering engine
88 closeTag := htmlClose
89 if flags&HTML_USE_XHTML != 0 {
90 closeTag = xhtmlClose
91 }
92
93 return &Html{
94 flags: flags,
95 closeTag: closeTag,
96 title: title,
97 css: css,
98
99 headerCount: 0,
100 currentLevel: 0,
101 toc: new(bytes.Buffer),
102
103 smartypants: smartypants(flags),
104 }
105}
106
107// Using if statements is a bit faster than a switch statement. As the compiler
108// improves, this should be unnecessary this is only worthwhile because
109// attrEscape is the single largest CPU user in normal use.
110// Also tried using map, but that gave a ~3x slowdown.
111func escapeSingleChar(char byte) (string, bool) {
112 if char == '"' {
113 return """, true
114 }
115 if char == '&' {
116 return "&", true
117 }
118 if char == '<' {
119 return "<", true
120 }
121 if char == '>' {
122 return ">", true
123 }
124 return "", false
125}
126
127func attrEscape(out *bytes.Buffer, src []byte) {
128 org := 0
129 for i, ch := range src {
130 if entity, ok := escapeSingleChar(ch); ok {
131 if i > org {
132 // copy all the normal characters since the last escape
133 out.Write(src[org:i])
134 }
135 org = i + 1
136 out.WriteString(entity)
137 }
138 }
139 if org < len(src) {
140 out.Write(src[org:])
141 }
142}
143
144func entityEscapeWithSkip(out *bytes.Buffer, src []byte, skipRanges [][]int) {
145 end := 0
146 for _, rang := range skipRanges {
147 attrEscape(out, src[end:rang[0]])
148 out.Write(src[rang[0]:rang[1]])
149 end = rang[1]
150 }
151 attrEscape(out, src[end:])
152}
153
154func (options *Html) GetFlags() int {
155 return options.flags
156}
157
158func (options *Html) Header(out *bytes.Buffer, text func() bool, level int, id string) {
159 marker := out.Len()
160 doubleSpace(out)
161
162 if id != "" {
163 out.WriteString(fmt.Sprintf("<h%d id=\"%s\">", level, id))
164 } else if options.flags&HTML_TOC != 0 {
165 // headerCount is incremented in htmlTocHeader
166 out.WriteString(fmt.Sprintf("<h%d id=\"toc_%d\">", level, options.headerCount))
167 } else {
168 out.WriteString(fmt.Sprintf("<h%d>", level))
169 }
170
171 tocMarker := out.Len()
172 if !text() {
173 out.Truncate(marker)
174 return
175 }
176
177 // are we building a table of contents?
178 if options.flags&HTML_TOC != 0 {
179 options.TocHeader(out.Bytes()[tocMarker:], level)
180 }
181
182 out.WriteString(fmt.Sprintf("</h%d>\n", level))
183}
184
185func (options *Html) BlockHtml(out *bytes.Buffer, text []byte) {
186 if options.flags&HTML_SKIP_HTML != 0 {
187 return
188 }
189
190 doubleSpace(out)
191 out.Write(text)
192 out.WriteByte('\n')
193}
194
195func (options *Html) HRule(out *bytes.Buffer) {
196 doubleSpace(out)
197 out.WriteString("<hr")
198 out.WriteString(options.closeTag)
199}
200
201func (options *Html) BlockCode(out *bytes.Buffer, text []byte, lang string) {
202 if options.flags&HTML_GITHUB_BLOCKCODE != 0 {
203 options.BlockCodeGithub(out, text, lang)
204 } else {
205 options.BlockCodeNormal(out, text, lang)
206 }
207}
208
209func (options *Html) BlockCodeNormal(out *bytes.Buffer, text []byte, lang string) {
210 doubleSpace(out)
211
212 // parse out the language names/classes
213 count := 0
214 for _, elt := range strings.Fields(lang) {
215 if elt[0] == '.' {
216 elt = elt[1:]
217 }
218 if len(elt) == 0 {
219 continue
220 }
221 if count == 0 {
222 out.WriteString("<pre><code class=\"")
223 } else {
224 out.WriteByte(' ')
225 }
226 attrEscape(out, []byte(elt))
227 count++
228 }
229
230 if count == 0 {
231 out.WriteString("<pre><code>")
232 } else {
233 out.WriteString("\">")
234 }
235
236 attrEscape(out, text)
237 out.WriteString("</code></pre>\n")
238}
239
240// GitHub style code block:
241//
242// <pre lang="LANG"><code>
243// ...
244// </code></pre>
245//
246// Unlike other parsers, we store the language identifier in the <pre>,
247// and don't let the user generate custom classes.
248//
249// The language identifier in the <pre> block gets postprocessed and all
250// the code inside gets syntax highlighted with Pygments. This is much safer
251// than letting the user specify a CSS class for highlighting.
252//
253// Note that we only generate HTML for the first specifier.
254// E.g.
255// ~~~~ {.python .numbered} => <pre lang="python"><code>
256func (options *Html) BlockCodeGithub(out *bytes.Buffer, text []byte, lang string) {
257 doubleSpace(out)
258
259 // parse out the language name
260 count := 0
261 for _, elt := range strings.Fields(lang) {
262 if elt[0] == '.' {
263 elt = elt[1:]
264 }
265 if len(elt) == 0 {
266 continue
267 }
268 out.WriteString("<pre lang=\"")
269 attrEscape(out, []byte(elt))
270 out.WriteString("\"><code>")
271 count++
272 break
273 }
274
275 if count == 0 {
276 out.WriteString("<pre><code>")
277 }
278
279 attrEscape(out, text)
280 out.WriteString("</code></pre>\n")
281}
282
283func (options *Html) BlockQuote(out *bytes.Buffer, text []byte) {
284 doubleSpace(out)
285 out.WriteString("<blockquote>\n")
286 out.Write(text)
287 out.WriteString("</blockquote>\n")
288}
289
290func (options *Html) Table(out *bytes.Buffer, header []byte, body []byte, columnData []int) {
291 doubleSpace(out)
292 out.WriteString("<table>\n<thead>\n")
293 out.Write(header)
294 out.WriteString("</thead>\n\n<tbody>\n")
295 out.Write(body)
296 out.WriteString("</tbody>\n</table>\n")
297}
298
299func (options *Html) TableRow(out *bytes.Buffer, text []byte) {
300 doubleSpace(out)
301 out.WriteString("<tr>\n")
302 out.Write(text)
303 out.WriteString("\n</tr>\n")
304}
305
306func (options *Html) TableHeaderCell(out *bytes.Buffer, text []byte, align int) {
307 doubleSpace(out)
308 switch align {
309 case TABLE_ALIGNMENT_LEFT:
310 out.WriteString("<th align=\"left\">")
311 case TABLE_ALIGNMENT_RIGHT:
312 out.WriteString("<th align=\"right\">")
313 case TABLE_ALIGNMENT_CENTER:
314 out.WriteString("<th align=\"center\">")
315 default:
316 out.WriteString("<th>")
317 }
318
319 out.Write(text)
320 out.WriteString("</th>")
321}
322
323func (options *Html) TableCell(out *bytes.Buffer, text []byte, align int) {
324 doubleSpace(out)
325 switch align {
326 case TABLE_ALIGNMENT_LEFT:
327 out.WriteString("<td align=\"left\">")
328 case TABLE_ALIGNMENT_RIGHT:
329 out.WriteString("<td align=\"right\">")
330 case TABLE_ALIGNMENT_CENTER:
331 out.WriteString("<td align=\"center\">")
332 default:
333 out.WriteString("<td>")
334 }
335
336 out.Write(text)
337 out.WriteString("</td>")
338}
339
340func (options *Html) Footnotes(out *bytes.Buffer, text func() bool) {
341 out.WriteString("<div class=\"footnotes\">\n")
342 options.HRule(out)
343 options.List(out, text, LIST_TYPE_ORDERED)
344 out.WriteString("</div>\n")
345}
346
347func (options *Html) FootnoteItem(out *bytes.Buffer, name, text []byte, flags int) {
348 if flags&LIST_ITEM_CONTAINS_BLOCK != 0 || flags&LIST_ITEM_BEGINNING_OF_LIST != 0 {
349 doubleSpace(out)
350 }
351 out.WriteString(`<li id="fn:`)
352 out.Write(slugify(name))
353 out.WriteString(`">`)
354 out.Write(text)
355 out.WriteString("</li>\n")
356}
357
358func (options *Html) List(out *bytes.Buffer, text func() bool, flags int) {
359 marker := out.Len()
360 doubleSpace(out)
361
362 if flags&LIST_TYPE_ORDERED != 0 {
363 out.WriteString("<ol>")
364 } else {
365 out.WriteString("<ul>")
366 }
367 if !text() {
368 out.Truncate(marker)
369 return
370 }
371 if flags&LIST_TYPE_ORDERED != 0 {
372 out.WriteString("</ol>\n")
373 } else {
374 out.WriteString("</ul>\n")
375 }
376}
377
378func (options *Html) ListItem(out *bytes.Buffer, text []byte, flags int) {
379 if flags&LIST_ITEM_CONTAINS_BLOCK != 0 || flags&LIST_ITEM_BEGINNING_OF_LIST != 0 {
380 doubleSpace(out)
381 }
382 out.WriteString("<li>")
383 out.Write(text)
384 out.WriteString("</li>\n")
385}
386
387func (options *Html) Paragraph(out *bytes.Buffer, text func() bool) {
388 marker := out.Len()
389 doubleSpace(out)
390
391 out.WriteString("<p>")
392 if !text() {
393 out.Truncate(marker)
394 return
395 }
396 out.WriteString("</p>\n")
397}
398
399func (options *Html) AutoLink(out *bytes.Buffer, link []byte, kind int) {
400 skipRanges := htmlEntity.FindAllIndex(link, -1)
401 if options.flags&HTML_SAFELINK != 0 && !isSafeLink(link) && kind != LINK_TYPE_EMAIL {
402 // mark it but don't link it if it is not a safe link: no smartypants
403 out.WriteString("<tt>")
404 entityEscapeWithSkip(out, link, skipRanges)
405 out.WriteString("</tt>")
406 return
407 }
408
409 out.WriteString("<a href=\"")
410 if kind == LINK_TYPE_EMAIL {
411 out.WriteString("mailto:")
412 }
413 entityEscapeWithSkip(out, link, skipRanges)
414 out.WriteString("\">")
415
416 // Pretty print: if we get an email address as
417 // an actual URI, e.g. `mailto:foo@bar.com`, we don't
418 // want to print the `mailto:` prefix
419 switch {
420 case bytes.HasPrefix(link, []byte("mailto://")):
421 attrEscape(out, link[len("mailto://"):])
422 case bytes.HasPrefix(link, []byte("mailto:")):
423 attrEscape(out, link[len("mailto:"):])
424 default:
425 entityEscapeWithSkip(out, link, skipRanges)
426 }
427
428 out.WriteString("</a>")
429}
430
431func (options *Html) CodeSpan(out *bytes.Buffer, text []byte) {
432 out.WriteString("<code>")
433 attrEscape(out, text)
434 out.WriteString("</code>")
435}
436
437func (options *Html) DoubleEmphasis(out *bytes.Buffer, text []byte) {
438 out.WriteString("<strong>")
439 out.Write(text)
440 out.WriteString("</strong>")
441}
442
443func (options *Html) Emphasis(out *bytes.Buffer, text []byte) {
444 if len(text) == 0 {
445 return
446 }
447 out.WriteString("<em>")
448 out.Write(text)
449 out.WriteString("</em>")
450}
451
452func (options *Html) Image(out *bytes.Buffer, link []byte, title []byte, alt []byte) {
453 if options.flags&HTML_SKIP_IMAGES != 0 {
454 return
455 }
456
457 out.WriteString("<img src=\"")
458 attrEscape(out, link)
459 out.WriteString("\" alt=\"")
460 if len(alt) > 0 {
461 attrEscape(out, alt)
462 }
463 if len(title) > 0 {
464 out.WriteString("\" title=\"")
465 attrEscape(out, title)
466 }
467
468 out.WriteByte('"')
469 out.WriteString(options.closeTag)
470 return
471}
472
473func (options *Html) LineBreak(out *bytes.Buffer) {
474 out.WriteString("<br")
475 out.WriteString(options.closeTag)
476}
477
478func (options *Html) Link(out *bytes.Buffer, link []byte, title []byte, content []byte) {
479 if options.flags&HTML_SKIP_LINKS != 0 {
480 // write the link text out but don't link it, just mark it with typewriter font
481 out.WriteString("<tt>")
482 attrEscape(out, content)
483 out.WriteString("</tt>")
484 return
485 }
486
487 if options.flags&HTML_SAFELINK != 0 && !isSafeLink(link) {
488 // write the link text out but don't link it, just mark it with typewriter font
489 out.WriteString("<tt>")
490 attrEscape(out, content)
491 out.WriteString("</tt>")
492 return
493 }
494
495 out.WriteString("<a href=\"")
496 attrEscape(out, link)
497 if len(title) > 0 {
498 out.WriteString("\" title=\"")
499 attrEscape(out, title)
500 }
501 if options.flags&HTML_NOFOLLOW_LINKS != 0 {
502 out.WriteString("\" rel=\"nofollow")
503 }
504 out.WriteString("\">")
505 out.Write(content)
506 out.WriteString("</a>")
507 return
508}
509
510func (options *Html) RawHtmlTag(out *bytes.Buffer, text []byte) {
511 if options.flags&HTML_SKIP_HTML != 0 {
512 return
513 }
514 if options.flags&HTML_SKIP_STYLE != 0 && isHtmlTag(text, "style") {
515 return
516 }
517 if options.flags&HTML_SKIP_LINKS != 0 && isHtmlTag(text, "a") {
518 return
519 }
520 if options.flags&HTML_SKIP_IMAGES != 0 && isHtmlTag(text, "img") {
521 return
522 }
523 out.Write(text)
524}
525
526func (options *Html) TripleEmphasis(out *bytes.Buffer, text []byte) {
527 out.WriteString("<strong><em>")
528 out.Write(text)
529 out.WriteString("</em></strong>")
530}
531
532func (options *Html) StrikeThrough(out *bytes.Buffer, text []byte) {
533 out.WriteString("<del>")
534 out.Write(text)
535 out.WriteString("</del>")
536}
537
538func (options *Html) FootnoteRef(out *bytes.Buffer, ref []byte, id int) {
539 slug := slugify(ref)
540 out.WriteString(`<sup class="footnote-ref" id="fnref:`)
541 out.Write(slug)
542 out.WriteString(`"><a rel="footnote" href="#fn:`)
543 out.Write(slug)
544 out.WriteString(`">`)
545 out.WriteString(strconv.Itoa(id))
546 out.WriteString(`</a></sup>`)
547}
548
549func (options *Html) Entity(out *bytes.Buffer, entity []byte) {
550 out.Write(entity)
551}
552
553func (options *Html) NormalText(out *bytes.Buffer, text []byte) {
554 if options.flags&HTML_USE_SMARTYPANTS != 0 {
555 options.Smartypants(out, text)
556 } else {
557 attrEscape(out, text)
558 }
559}
560
561func (options *Html) Smartypants(out *bytes.Buffer, text []byte) {
562 smrt := smartypantsData{false, false}
563
564 // first do normal entity escaping
565 var escaped bytes.Buffer
566 attrEscape(&escaped, text)
567 text = escaped.Bytes()
568
569 mark := 0
570 for i := 0; i < len(text); i++ {
571 if action := options.smartypants[text[i]]; action != nil {
572 if i > mark {
573 out.Write(text[mark:i])
574 }
575
576 previousChar := byte(0)
577 if i > 0 {
578 previousChar = text[i-1]
579 }
580 i += action(out, &smrt, previousChar, text[i:])
581 mark = i + 1
582 }
583 }
584
585 if mark < len(text) {
586 out.Write(text[mark:])
587 }
588}
589
590func (options *Html) DocumentHeader(out *bytes.Buffer) {
591 if options.flags&HTML_COMPLETE_PAGE == 0 {
592 return
593 }
594
595 ending := ""
596 if options.flags&HTML_USE_XHTML != 0 {
597 out.WriteString("<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" ")
598 out.WriteString("\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">\n")
599 out.WriteString("<html xmlns=\"http://www.w3.org/1999/xhtml\">\n")
600 ending = " /"
601 } else {
602 out.WriteString("<!DOCTYPE html>\n")
603 out.WriteString("<html>\n")
604 }
605 out.WriteString("<head>\n")
606 out.WriteString(" <title>")
607 options.NormalText(out, []byte(options.title))
608 out.WriteString("</title>\n")
609 out.WriteString(" <meta name=\"GENERATOR\" content=\"Blackfriday Markdown Processor v")
610 out.WriteString(VERSION)
611 out.WriteString("\"")
612 out.WriteString(ending)
613 out.WriteString(">\n")
614 out.WriteString(" <meta charset=\"utf-8\"")
615 out.WriteString(ending)
616 out.WriteString(">\n")
617 if options.css != "" {
618 out.WriteString(" <link rel=\"stylesheet\" type=\"text/css\" href=\"")
619 attrEscape(out, []byte(options.css))
620 out.WriteString("\"")
621 out.WriteString(ending)
622 out.WriteString(">\n")
623 }
624 out.WriteString("</head>\n")
625 out.WriteString("<body>\n")
626
627 options.tocMarker = out.Len()
628}
629
630func (options *Html) DocumentFooter(out *bytes.Buffer) {
631 // finalize and insert the table of contents
632 if options.flags&HTML_TOC != 0 {
633 options.TocFinalize()
634
635 // now we have to insert the table of contents into the document
636 var temp bytes.Buffer
637
638 // start by making a copy of everything after the document header
639 temp.Write(out.Bytes()[options.tocMarker:])
640
641 // now clear the copied material from the main output buffer
642 out.Truncate(options.tocMarker)
643
644 // corner case spacing issue
645 if options.flags&HTML_COMPLETE_PAGE != 0 {
646 out.WriteByte('\n')
647 }
648
649 // insert the table of contents
650 out.WriteString("<nav>\n")
651 out.Write(options.toc.Bytes())
652 out.WriteString("</nav>\n")
653
654 // corner case spacing issue
655 if options.flags&HTML_COMPLETE_PAGE == 0 && options.flags&HTML_OMIT_CONTENTS == 0 {
656 out.WriteByte('\n')
657 }
658
659 // write out everything that came after it
660 if options.flags&HTML_OMIT_CONTENTS == 0 {
661 out.Write(temp.Bytes())
662 }
663 }
664
665 if options.flags&HTML_COMPLETE_PAGE != 0 {
666 out.WriteString("\n</body>\n")
667 out.WriteString("</html>\n")
668 }
669
670}
671
672func (options *Html) TocHeader(text []byte, level int) {
673 for level > options.currentLevel {
674 switch {
675 case bytes.HasSuffix(options.toc.Bytes(), []byte("</li>\n")):
676 // this sublist can nest underneath a header
677 size := options.toc.Len()
678 options.toc.Truncate(size - len("</li>\n"))
679
680 case options.currentLevel > 0:
681 options.toc.WriteString("<li>")
682 }
683 if options.toc.Len() > 0 {
684 options.toc.WriteByte('\n')
685 }
686 options.toc.WriteString("<ul>\n")
687 options.currentLevel++
688 }
689
690 for level < options.currentLevel {
691 options.toc.WriteString("</ul>")
692 if options.currentLevel > 1 {
693 options.toc.WriteString("</li>\n")
694 }
695 options.currentLevel--
696 }
697
698 options.toc.WriteString("<li><a href=\"#toc_")
699 options.toc.WriteString(strconv.Itoa(options.headerCount))
700 options.toc.WriteString("\">")
701 options.headerCount++
702
703 options.toc.Write(text)
704
705 options.toc.WriteString("</a></li>\n")
706}
707
708func (options *Html) TocFinalize() {
709 for options.currentLevel > 1 {
710 options.toc.WriteString("</ul></li>\n")
711 options.currentLevel--
712 }
713
714 if options.currentLevel > 0 {
715 options.toc.WriteString("</ul>\n")
716 }
717}
718
719func isHtmlTag(tag []byte, tagname string) bool {
720 found, _ := findHtmlTagPos(tag, tagname)
721 return found
722}
723
724// Look for a character, but ignore it when it's in any kind of quotes, it
725// might be JavaScript
726func skipUntilCharIgnoreQuotes(html []byte, start int, char byte) int {
727 inSingleQuote := false
728 inDoubleQuote := false
729 inGraveQuote := false
730 i := start
731 for i < len(html) {
732 switch {
733 case html[i] == char && !inSingleQuote && !inDoubleQuote && !inGraveQuote:
734 return i
735 case html[i] == '\'':
736 inSingleQuote = !inSingleQuote
737 case html[i] == '"':
738 inDoubleQuote = !inDoubleQuote
739 case html[i] == '`':
740 inGraveQuote = !inGraveQuote
741 }
742 i++
743 }
744 return start
745}
746
747func findHtmlTagPos(tag []byte, tagname string) (bool, int) {
748 i := 0
749 if i < len(tag) && tag[0] != '<' {
750 return false, -1
751 }
752 i++
753 i = skipSpace(tag, i)
754
755 if i < len(tag) && tag[i] == '/' {
756 i++
757 }
758
759 i = skipSpace(tag, i)
760 j := 0
761 for ; i < len(tag); i, j = i+1, j+1 {
762 if j >= len(tagname) {
763 break
764 }
765
766 if strings.ToLower(string(tag[i]))[0] != tagname[j] {
767 return false, -1
768 }
769 }
770
771 if i == len(tag) {
772 return false, -1
773 }
774
775 rightAngle := skipUntilCharIgnoreQuotes(tag, i, '>')
776 if rightAngle > i {
777 return true, rightAngle
778 }
779
780 return false, -1
781}
782
783func skipUntilChar(text []byte, start int, char byte) int {
784 i := start
785 for i < len(text) && text[i] != char {
786 i++
787 }
788 return i
789}
790
791func skipSpace(tag []byte, i int) int {
792 for i < len(tag) && isspace(tag[i]) {
793 i++
794 }
795 return i
796}
797
798func doubleSpace(out *bytes.Buffer) {
799 if out.Len() > 0 {
800 out.WriteByte('\n')
801 }
802}