1 // Copyright 2020 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.
5 // Present2md converts legacy-syntax present files to Markdown-syntax present files.
9 // present2md [-w] [file ...]
11 // By default, present2md prints the Markdown-syntax form of each input file to standard output.
12 // If no input file is listed, standard input is used.
14 // The -w flag causes present2md to update the files in place, overwriting each with its
15 // Markdown-syntax equivalent.
19 // present2md your.article
20 // present2md -w *.article
37 "golang.org/x/tools/present"
41 fmt.Fprintf(os.Stderr, "usage: present2md [-w] [file ...]\n")
46 writeBack = flag.Bool("w", false, "write conversions back to original files")
51 log.SetPrefix("present2md: ")
59 log.Fatalf("cannot use -w with standard input")
61 convert(os.Stdin, "stdin", false)
65 for _, arg := range args {
66 f, err := os.Open(arg)
72 err = convert(f, arg, *writeBack)
82 // convert reads the data from r, parses it as legacy present,
83 // and converts it to Markdown-enabled present.
84 // If any errors occur, the data is reported as coming from file.
85 // If writeBack is true, the converted version is written back to file.
86 // If writeBack is false, the converted version is printed to standard output.
87 func convert(r io.Reader, file string, writeBack bool) error {
88 data, err := ioutil.ReadAll(r)
92 if bytes.HasPrefix(data, []byte("# ")) {
93 return fmt.Errorf("%v: already markdown", file)
96 doc, err := present.Parse(bytes.NewReader(data), file, 0)
101 // Title and Subtitle, Time, Tags.
103 fmt.Fprintf(&md, "# %s\n", doc.Title)
104 if doc.Subtitle != "" {
105 fmt.Fprintf(&md, "%s\n", doc.Subtitle)
107 if !doc.Time.IsZero() {
108 fmt.Fprintf(&md, "%s\n", doc.Time.Format("2 Jan 2006"))
110 if len(doc.Tags) > 0 {
111 fmt.Fprintf(&md, "Tags: %s\n", strings.Join(doc.Tags, ", "))
114 // Summary, defaulting to first paragraph of section.
115 // (Summaries must be explicit for Markdown-enabled present,
116 // and the expectation is that they will be shorter than the
117 // whole first paragraph. But this is what the blog does today.)
118 if strings.HasSuffix(file, ".article") && len(doc.Sections) > 0 {
119 for _, elem := range doc.Sections[0].Elem {
120 text, ok := elem.(present.Text)
122 // skip everything but non-text elements
125 fmt.Fprintf(&md, "Summary:")
126 for i, line := range text.Lines {
127 fmt.Fprintf(&md, " ")
128 printStyled(&md, line, i == 0)
130 fmt.Fprintf(&md, "\n")
136 for _, a := range doc.Authors {
137 fmt.Fprintf(&md, "\n")
138 for _, elem := range a.Elem {
139 switch elem := elem.(type) {
141 // Can only happen if this type switch is incomplete, which is a bug.
142 log.Fatalf("%s: unexpected author type %T", file, elem)
144 for _, line := range elem.Lines {
145 fmt.Fprintf(&md, "%s\n", markdownEscape(line, true))
148 fmt.Fprintf(&md, "%s\n", markdownEscape(elem.Label, true))
153 // Invariant: the output ends in non-blank line now,
154 // and after printing any piece of the file below,
155 // the output should still end in a non-blank line.
156 // If a blank line separator is needed, it should be printed
157 // before the block that needs separating, not after.
159 if len(doc.TitleNotes) > 0 {
160 fmt.Fprintf(&md, "\n")
161 for _, line := range doc.TitleNotes {
162 fmt.Fprintf(&md, ": %s\n", line)
166 if len(doc.Sections) == 1 && strings.HasSuffix(file, ".article") {
167 // Blog drops section headers when there is only one section.
168 // Don't print a title in this case, to make clear that it's being dropped.
169 fmt.Fprintf(&md, "\n##\n")
170 printSectionBody(file, 1, &md, doc.Sections[0].Elem)
172 for _, s := range doc.Sections {
173 fmt.Fprintf(&md, "\n")
174 fmt.Fprintf(&md, "## %s\n", markdownEscape(s.Title, false))
175 printSectionBody(file, 1, &md, s.Elem)
180 os.Stdout.Write(md.Bytes())
183 return ioutil.WriteFile(file, md.Bytes(), 0666)
186 func printSectionBody(file string, depth int, w *bytes.Buffer, elems []present.Elem) {
187 for _, elem := range elems {
188 switch elem := elem.(type) {
190 // Can only happen if this type switch is incomplete, which is a bug.
191 log.Fatalf("%s: unexpected present element type %T", file, elem)
196 for len(lines) > 0 && lines[0] == "" {
200 for _, line := range strings.Split(strings.TrimRight(elem.Raw, "\n"), "\n") {
204 fmt.Fprintf(w, "\t%s\n", line)
208 for _, line := range elem.Lines {
209 printStyled(w, line, true)
216 for _, item := range elem.Bullet {
217 fmt.Fprintf(w, " - ")
218 for i, line := range strings.Split(item, "\n") {
222 printStyled(w, line, false)
227 case present.Section:
230 if elem.Title == "" {
233 fmt.Fprintf(w, "%s%s%s\n", strings.Repeat("#", depth+2), sep, markdownEscape(elem.Title, false))
234 printSectionBody(file, depth+1, w, elem.Elem)
236 case interface{ PresentCmd() string }:
237 // If there are multiple present commands in a row, don't print a blank line before the second etc.
241 i := bytes.LastIndexByte(b[:len(b)-1], '\n')
246 fmt.Fprintf(w, "%s%s\n", sep, elem.PresentCmd())
251 func markdownEscape(s string, startLine bool) string {
252 var b strings.Builder
253 for i, r := range s {
255 case r == '#' && i == 0,
258 r == '<' && (i == 0 || s[i-1] != ' ') && i+1 < len(s) && s[i+1] != ' ',
259 r == '[' && strings.Contains(s[i:], "]("):
267 // Copy of ../../present/style.go adjusted to produce Markdown instead of HTML.
270 Fonts are demarcated by an initial and final char bracketing a
271 space-delimited word, plus possibly some terminal punctuation.
275 ` (back quote) for fixed width.
276 Inner appearances of the char become spaces. For instance,
279 <i>this is italic</i>!
282 func printStyled(w *bytes.Buffer, text string, startLine bool) {
283 w.WriteString(font(text, startLine))
286 // font returns s with font indicators turned into HTML font tags.
287 func font(s string, startLine bool) string {
288 if !strings.ContainsAny(s, "[`_*") {
289 return markdownEscape(s, startLine)
294 for w, word := range words {
295 words[w] = markdownEscape(word, startLine && w == 0) // for all the continue Word
299 if link, _ := parseInlineLink(word); link != "" {
304 // Initial punctuation is OK but must be peeled off.
305 first := strings.IndexAny(word, marker)
309 // Opening marker must be at the beginning of the token or else preceded by punctuation.
311 r, _ := utf8.DecodeLastRuneInString(word[:first])
312 if !unicode.IsPunct(r) {
316 open, word := markdownEscape(word[:first], startLine && w == 0), word[first:]
317 char := word[0] // ASCII is OK.
332 // Closing marker must be at the end of the token or else followed by punctuation.
333 last := strings.LastIndex(word, word[:1])
337 if last+1 != len(word) {
338 r, _ := utf8.DecodeRuneInString(word[last+1:])
339 if !unicode.IsPunct(r) {
343 head, tail := word[:last+1], word[last+1:]
346 for i := 1; i < len(head)-1; i += wid {
348 r, wid = utf8.DecodeRuneInString(head[i:])
350 // Ordinary character.
354 if head[i+1] != char {
355 // Inner char becomes space.
359 // Doubled char becomes real char.
360 // Not worth worrying about "_x__".
362 wid++ // Consumed two chars, both ASCII.
366 for strings.Contains(text, close) {
371 text = markdownEscape(text, false)
373 words[w] = open + text + close + tail
375 return strings.Join(words, "")
378 // split is like strings.Fields but also returns the runs of spaces
379 // and treats inline links as distinct words.
380 func split(s string) []string {
382 words = make([]string, 0, 10)
386 // appendWord appends the string s[start:end] to the words slice.
387 // If the word contains the beginning of a link, the non-link portion
388 // of the word and the entire link are appended as separate words,
389 // and the start index is advanced to the end of the link.
390 appendWord := func(end int) {
391 if j := strings.Index(s[start:end], "[["); j > -1 {
392 if _, l := parseInlineLink(s[start+j:]); l > 0 {
393 // Append portion before link, if any.
395 words = append(words, s[start:start+j])
397 // Append link itself.
398 words = append(words, s[start+j:start+j+l])
399 // Advance start index to end of link.
400 start = start + j + l
404 // No link; just add the word.
405 words = append(words, s[start:end])
410 for i, r := range s {
411 isSpace := unicode.IsSpace(r)
412 if i > start && isSpace != wasSpace {
423 // parseInlineLink parses an inline link at the start of s, and returns
424 // a rendered Markdown link and the total length of the raw inline link.
425 // If no inline link is present, it returns all zeroes.
426 func parseInlineLink(s string) (link string, length int) {
427 if !strings.HasPrefix(s, "[[") {
430 end := strings.Index(s, "]]")
434 urlEnd := strings.Index(s, "]")
435 rawURL := s[2:urlEnd]
436 const badURLChars = `<>"{}|\^[] ` + "`" // per RFC2396 section 2.4.3
437 if strings.ContainsAny(rawURL, badURLChars) {
442 url, err := url.Parse(rawURL)
444 // If the URL is http://foo.com, drop the http://
445 // In other words, render [[http://golang.org]] as:
446 // <a href="http://golang.org">golang.org</a>
447 if strings.HasPrefix(rawURL, url.Scheme+"://") {
448 simpleURL = strings.TrimPrefix(rawURL, url.Scheme+"://")
449 } else if strings.HasPrefix(rawURL, url.Scheme+":") {
450 simpleURL = strings.TrimPrefix(rawURL, url.Scheme+":")
453 return renderLink(rawURL, simpleURL), end + 2
455 if s[urlEnd:urlEnd+2] != "][" {
458 text := s[urlEnd+2 : end]
459 return renderLink(rawURL, text), end + 2
462 func renderLink(href, text string) string {
463 text = font(text, false)
465 text = markdownEscape(href, false)
467 return "[" + text + "](" + href + ")"