github.com/dkischenko/gomarkdoc@v0.0.0-20230516135336-e40deae8a495/format/formatcore/base.go (about) 1 package formatcore 2 3 import ( 4 "errors" 5 "fmt" 6 "html" 7 "regexp" 8 "strings" 9 10 "github.com/russross/blackfriday/v2" 11 "mvdan.cc/xurls/v2" 12 ) 13 14 // Bold converts the provided text to bold 15 func Bold(text string) string { 16 if text == "" { 17 return "" 18 } 19 20 return fmt.Sprintf("**%s**", Escape(text)) 21 } 22 23 // CodeBlock wraps the provided code as a code block. Language syntax 24 // highlighting is not supported. 25 func CodeBlock(code string) string { 26 var builder strings.Builder 27 28 lines := strings.Split(code, "\n") 29 for i, line := range lines { 30 if i != 0 { 31 builder.WriteRune('\n') 32 } 33 34 builder.WriteRune('\t') 35 builder.WriteString(line) 36 } 37 38 return builder.String() 39 } 40 41 // GFMCodeBlock wraps the provided code as a code block and tags it with the 42 // provided language (or no language if the empty string is provided), using 43 // the triple backtick format from GitHub Flavored Markdown. 44 func GFMCodeBlock(language, code string) string { 45 return fmt.Sprintf("```%s\n%s\n```", language, strings.TrimSpace(code)) 46 } 47 48 // Anchor produces an anchor for the provided link. 49 func Anchor(anchor string) string { 50 return fmt.Sprintf( 51 "<a name=\"%s\"></a>", 52 html.EscapeString(anchor), 53 ) 54 } 55 56 // AnchorHeader converts the provided text and custom anchor link into a header 57 // of the provided level. The level is expected to be at least 1. 58 func AnchorHeader(level int, text, anchor string) (string, error) { 59 header, err := Header(level, text) 60 if err != nil { 61 return "", err 62 } 63 64 return fmt.Sprintf("%s\n%s", Anchor(anchor), header), nil 65 } 66 67 // Header converts the provided text into a header of the provided level. The 68 // level is expected to be at least 1. 69 func Header(level int, text string) (string, error) { 70 if level < 1 { 71 return "", errors.New("format: header level cannot be less than 1") 72 } 73 74 switch level { 75 case 1: 76 return fmt.Sprintf("# %s", text), nil 77 case 2: 78 return fmt.Sprintf("## %s", text), nil 79 case 3: 80 return fmt.Sprintf("### %s", text), nil 81 case 4: 82 return fmt.Sprintf("#### %s", text), nil 83 case 5: 84 return fmt.Sprintf("##### %s", text), nil 85 default: 86 // Only go up to 6 levels. Anything higher is also level 6 87 return fmt.Sprintf("###### %s", text), nil 88 } 89 } 90 91 // Link generates a link with the given text and href values. 92 func Link(text, href string) string { 93 if text == "" { 94 return "" 95 } 96 97 if href == "" { 98 return text 99 } 100 101 return fmt.Sprintf("[%s](<%s>)", text, href) 102 } 103 104 // ListEntry generates an unordered list entry with the provided text at the 105 // provided zero-indexed depth. A depth of 0 is considered the topmost level of 106 // list. 107 func ListEntry(depth int, text string) string { 108 // TODO: this is a weird special case 109 if text == "" { 110 return "" 111 } 112 113 prefix := strings.Repeat(" ", depth) 114 return fmt.Sprintf("%s- %s", prefix, text) 115 } 116 117 // GFMAccordion generates a collapsible content. The accordion's visible title 118 // while collapsed is the provided title and the expanded content is the body. 119 func GFMAccordion(title, body string) string { 120 return fmt.Sprintf("<details><summary>%s</summary>\n<p>%s</p>\n</details>", title, Escape(body)) 121 } 122 123 // GFMAccordionHeader generates the header visible when an accordion is 124 // collapsed. 125 // 126 // The GFMAccordionHeader is expected to be used in conjunction with 127 // GFMAccordionTerminator() when the demands of the body's rendering requires 128 // it to be generated independently. The result looks conceptually like the 129 // following: 130 // 131 // accordion := GFMAccordionHeader("Accordion Title") + "Accordion Body" + GFMAccordionTerminator() 132 func GFMAccordionHeader(title string) string { 133 return fmt.Sprintf("<details><summary>%s</summary>\n<p>", title) 134 } 135 136 // GFMAccordionTerminator generates the code necessary to terminate an 137 // accordion after the body. It is expected to be used in conjunction with 138 // GFMAccordionHeader(). See GFMAccordionHeader for a full description. 139 func GFMAccordionTerminator() string { 140 return "</p>\n</details>" 141 } 142 143 var ( 144 specialCharacterRegex = regexp.MustCompile("([\\\\`*_{}\\[\\]()<>#+\\-!~])") 145 urlRegex = xurls.Strict() // Require a scheme in URLs 146 ) 147 148 // Escape escapes the special characters in the provided text, but leaves URLs 149 // found intact. Note that the URLs included must begin with a scheme to skip 150 // the escaping. 151 func Escape(text string) string { 152 b := []byte(text) 153 154 var ( 155 cursor int 156 builder strings.Builder 157 ) 158 159 for _, urlLoc := range urlRegex.FindAllIndex(b, -1) { 160 // Walk through each found URL, escaping the text before the URL and 161 // leaving the text in the URL unchanged. 162 if urlLoc[0] > cursor { 163 // Escape the previous section if its length is nonzero 164 builder.Write(escapeRaw(b[cursor:urlLoc[0]])) 165 } 166 167 // Add the unescaped URL to the end of it 168 builder.Write(b[urlLoc[0]:urlLoc[1]]) 169 170 // Move the cursor forward for the next iteration 171 cursor = urlLoc[1] 172 } 173 174 // Escape the end of the string after the last URL if there's anything left 175 if len(b) > cursor { 176 builder.Write(escapeRaw(b[cursor:])) 177 } 178 179 return builder.String() 180 } 181 182 func escapeRaw(segment []byte) []byte { 183 return specialCharacterRegex.ReplaceAll(segment, []byte("\\$1")) 184 } 185 186 // PlainText converts a markdown string to the plain text that appears in the 187 // rendered output. 188 func PlainText(text string) string { 189 md := blackfriday.New(blackfriday.WithExtensions(blackfriday.CommonExtensions)) 190 node := md.Parse([]byte(text)) 191 192 var builder strings.Builder 193 plainTextInner(node, &builder) 194 195 return builder.String() 196 } 197 198 func plainTextInner(node *blackfriday.Node, builder *strings.Builder) { 199 // Only text nodes produce output 200 if node.Type == blackfriday.Text { 201 builder.Write(node.Literal) 202 } 203 204 // Run the children first 205 if node.FirstChild != nil { 206 plainTextInner(node.FirstChild, builder) 207 } 208 209 // Then run any other siblings 210 if node.Next != nil { 211 // Add extra space if necessary between nodes 212 if node.Type == blackfriday.Paragraph || 213 node.Type == blackfriday.CodeBlock || 214 node.Type == blackfriday.Heading { 215 builder.WriteRune(' ') 216 } 217 218 plainTextInner(node.Next, builder) 219 } 220 }