9 // markdownPunctuation holds the ASCII characters that a backslash makes plain.
10 const markdownPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
12 // MarkdownSpan is an inline mark of a line: the mark kind, the content inside
13 // the marks, the index after the closing mark, and the URL of a link.
14 type MarkdownSpan struct {
21 // markdownCodeFence returns the language of a line that opens a fenced code
22 // block, and the fence. The fence is "" when line does not open a block.
23 func markdownCodeFence(line string) (string, string) {
24 trimmed := strings.TrimLeft(line, " ")
25 if len(line)-len(trimmed) > 3 || !strings.HasPrefix(trimmed, "```") {
29 run := len(trimmed) - len(strings.TrimLeft(trimmed, "`"))
30 info := strings.TrimSpace(trimmed[run:])
31 if strings.Contains(info, "`") {
35 language, _, _ := strings.Cut(info, " ")
37 return language, trimmed[:run]
40 // markdownClosingFence returns the index of the line after index that closes
41 // the code block of fence, or -1 when fence is "" or no line closes it.
42 func markdownClosingFence(lines []string, index int, fence string) int {
47 for end := index + 1; end < len(lines); end++ {
48 if markdownClosesFence(lines[end], fence) {
56 // markdownClosesFence tells if line closes the code block of fence.
57 func markdownClosesFence(line, fence string) bool {
58 trimmed := strings.TrimSpace(line)
59 return strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == ""
62 // markdownHeadingText returns the text of an ATX heading line without its
63 // marks, or "" when line is not a heading or has no text.
64 func markdownHeadingText(line string) string {
65 trimmed := strings.TrimLeft(line, " ")
66 if len(line)-len(trimmed) > 3 {
70 level := len(trimmed) - len(strings.TrimLeft(trimmed, "#"))
71 if level == 0 || level > 6 {
75 rest := trimmed[level:]
76 if rest != "" && rest[0] != ' ' && rest[0] != '\t' {
80 rest = strings.TrimSpace(rest)
81 closed := strings.TrimRight(rest, "#")
82 if closed == "" || strings.HasSuffix(closed, " ") {
83 rest = strings.TrimSpace(closed)
89 // markdownMarkSpan returns the inline mark that starts at index of line and
90 // closes in line. It returns false when no mark starts there or the mark does
92 func markdownMarkSpan(line string, index int) (MarkdownSpan, bool) {
95 return markdownCodeSpan(line, index)
97 return markdownLinkSpan(line, index)
99 return markdownDelimited(line, index, "~~", "strikethrough")
101 double := line[index : index+1]
103 span, found := markdownDelimited(line, index, double, "bold")
108 return markdownDelimited(line, index, line[index:index+1], "italic")
110 return MarkdownSpan{}, false
114 // markdownCodeSpan returns the inline code that starts at index: a run of
115 // backticks, the code, and a run of the same length. One space on each side of
116 // the code is left out, as in Markdown.
117 func markdownCodeSpan(line string, index int) (MarkdownSpan, bool) {
118 run := markdownBackticks(line[index:])
119 for search := index + run; search < len(line); {
120 next := strings.IndexByte(line[search:], '`')
126 length := markdownBackticks(line[next:])
128 search = next + length
132 content := line[index+run : next]
133 if strings.TrimSpace(content) == "" {
137 if len(content) > 1 && content[0] == ' ' && content[len(content)-1] == ' ' {
138 content = content[1 : len(content)-1]
148 return MarkdownSpan{}, false
151 // markdownLinkSpan returns the link [text](url) that starts at index. The text
152 // can hold brackets in pairs, and the URL holds no space.
153 func markdownLinkSpan(line string, index int) (MarkdownSpan, bool) {
155 for closing := index; closing < len(line); closing++ {
156 switch line[closing] {
169 rest := line[closing+1:]
170 if closing == index+1 || !strings.HasPrefix(rest, "(") {
171 return MarkdownSpan{}, false
174 url, _, found := strings.Cut(rest[1:], ")")
175 if !found || url == "" || strings.ContainsAny(url, " \t") {
176 return MarkdownSpan{}, false
181 content: line[index+1 : closing],
182 end: closing + 2 + len(url) + 1,
187 return MarkdownSpan{}, false
190 // markdownDelimited returns the span of kind between the mark at index and the
191 // same mark later in line. An opening mark is followed by a character that is
192 // not a space, and a closing mark follows one. A single mark is not a part of a
193 // double one. A mark of underscores does not open or close inside a word, so
194 // snake_case stays as text. A code span inside the content is skipped whole.
195 func markdownDelimited(line string, index int, mark, kind string) (MarkdownSpan, bool) {
197 after := index + len(mark)
198 if !strings.HasPrefix(line[index:], mark) || after >= len(line) || markdownIsSpace(line[after]) {
199 return MarkdownSpan{}, false
202 if len(mark) == 1 && (line[after] == char || index > 0 && line[index-1] == char) {
203 return MarkdownSpan{}, false
206 if char == '_' && index > 0 && markdownWordCharBefore(line[:index]) {
207 return MarkdownSpan{}, false
210 for closing := after + 1; closing < len(line); closing++ {
212 case line[closing] == '\\':
215 case line[closing] == '`':
216 span, found := markdownCodeSpan(line, closing)
218 closing = span.end - 1
222 case !strings.HasPrefix(line[closing:], mark) || markdownIsSpace(line[closing-1]):
226 end := closing + len(mark)
227 if len(mark) == 1 && (line[closing-1] == char || end < len(line) && line[end] == char) {
231 if char == '_' && markdownWordChar(line[end:]) {
237 content: line[after:closing],
242 return MarkdownSpan{}, false
245 // markdownBackticks returns the length of the run of backticks at the start of
247 func markdownBackticks(text string) int {
248 return len(text) - len(strings.TrimLeft(text, "`"))
251 // markdownIsSpace tells if char is a space, a tab, or a line break.
252 func markdownIsSpace(char byte) bool {
253 return char == ' ' || char == '\t' || char == '\n' || char == '\r'
256 // markdownWordCharBefore tells if the character that ends text is a letter or a
258 func markdownWordCharBefore(text string) bool {
259 char, _ := utf8.DecodeLastRuneInString(text)
260 return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
263 // markdownWordChar tells if the character that starts text is a letter or a
264 // digit. An underscore next to one is a part of a word, such as in snake_case.
265 func markdownWordChar(text string) bool {
266 char, _ := utf8.DecodeRuneInString(text)
267 return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))