md.gno
17.99 Kb · 505 lines
1// Package md provides helper functions for generating Markdown content programmatically.
2//
3// It includes utilities for text formatting, creating lists, blockquotes, code blocks,
4// links, images, and more.
5//
6// Highlights:
7// - Supports basic Markdown syntax such as bold, italic, strikethrough, headers, and lists.
8// - Manages multiline support in lists (e.g., bullet, ordered, and todo lists).
9// - Includes advanced helpers like inline images with links and nested list prefixes.
10//
11// For a comprehensive example of how to use these helpers, see:
12// https://gno.land/r/docs/moul_md
13//
14// # Sanitization contract
15//
16// Some helpers in this package sanitize their user-derived arguments
17// INTERNALLY (via p/nt/markdown/sanitize/v0). When using these, pass
18// raw user input — do NOT pre-wrap with sanitize.*, or you will
19// double-wrap (the escapers are not idempotent and double-wrap is a
20// bug):
21//
22// Link, UserLink, Image, InlineImageWithLink, FootnoteDefinition,
23// LinkReferenceDefinition, CollapsibleSection (title only),
24// InlineCode, CodeBlock, LanguageCodeBlock, Blockquote
25//
26// The other helpers DO NOT sanitize — they are pure builders that
27// wrap their input in markdown chrome. User-derived input reaches
28// the output unmodified, so callers MUST wrap with sanitize.* at the
29// call site:
30//
31// Bold, Italic, Strikethrough, H1-H6, BulletList, BulletItem,
32// OrderedList, TodoList, TodoItem, Nested, Paragraph, Columns,
33// ColumnsN, HorizontalRule
34//
35// Examples:
36//
37// // Sanitizing helper — pass raw:
38// out += md.Link(post.Title, post.URL) // good
39// out += md.Link(sanitize.InlineText(post.Title), sanitize.URL(post.URL)) // BAD: double-wrap
40//
41// // Non-sanitizing helper — wrap once:
42// out += md.H2(sanitize.InlineText(post.Title)) // good
43// out += md.H2(post.Title) // BAD: raw user input
44// out += md.H2(sanitize.InlineText(sanitize.InlineText(post.Title))) // BAD: double-wrap
45//
46// Composition: outputs of sanitizing helpers are safe markdown chrome
47// and can be embedded inside non-sanitizing helpers freely:
48//
49// out += md.H2(md.Link(post.Title, post.URL)) // good — H2 doesn't re-escape Link's output
50//
51// The reverse is unsafe: do NOT embed a non-sanitizing helper's
52// markdown chrome inside a sanitizing helper's arg, or the inner
53// markdown gets re-escaped:
54//
55// out += md.Link(md.Bold(post.Title), post.URL) // BAD: Link's internal sanitize
56// // escapes the ** chars from md.Bold
57package md
58
59import (
60 "strconv"
61 "strings"
62
63 "gno.land/p/nt/markdown/sanitize/v0"
64)
65
66// Bold returns bold text for markdown.
67// Example: Bold("foo") => "**foo**"
68func Bold(text string) string {
69 return "**" + text + "**"
70}
71
72// Italic returns italicized text for markdown.
73// Example: Italic("foo") => "*foo*"
74func Italic(text string) string {
75 return "*" + text + "*"
76}
77
78// Strikethrough returns strikethrough text for markdown.
79// Example: Strikethrough("foo") => "~~foo~~"
80func Strikethrough(text string) string {
81 return "~~" + text + "~~"
82}
83
84// H1 returns a level 1 header for markdown.
85// Example: H1("foo") => "# foo\n"
86func H1(text string) string {
87 return "# " + text + "\n"
88}
89
90// H2 returns a level 2 header for markdown.
91// Example: H2("foo") => "## foo\n"
92func H2(text string) string {
93 return "## " + text + "\n"
94}
95
96// H3 returns a level 3 header for markdown.
97// Example: H3("foo") => "### foo\n"
98func H3(text string) string {
99 return "### " + text + "\n"
100}
101
102// H4 returns a level 4 header for markdown.
103// Example: H4("foo") => "#### foo\n"
104func H4(text string) string {
105 return "#### " + text + "\n"
106}
107
108// H5 returns a level 5 header for markdown.
109// Example: H5("foo") => "##### foo\n"
110func H5(text string) string {
111 return "##### " + text + "\n"
112}
113
114// H6 returns a level 6 header for markdown.
115// Example: H6("foo") => "###### foo\n"
116func H6(text string) string {
117 return "###### " + text + "\n"
118}
119
120// BulletList returns a bullet list for markdown.
121// Example: BulletList([]string{"foo", "bar"}) => "- foo\n- bar\n"
122func BulletList(items []string) string {
123 var sb strings.Builder
124 for _, item := range items {
125 sb.WriteString(BulletItem(item))
126 }
127 return sb.String()
128}
129
130// BulletItem returns a bullet item for markdown.
131// Example: BulletItem("foo") => "- foo\n"
132func BulletItem(item string) string {
133 var sb strings.Builder
134 lines := strings.Split(item, "\n")
135 sb.WriteString("- " + lines[0] + "\n")
136 for _, line := range lines[1:] {
137 sb.WriteString(" " + line + "\n")
138 }
139 return sb.String()
140}
141
142// OrderedList returns an ordered list for markdown.
143// Example: OrderedList([]string{"foo", "bar"}) => "1. foo\n2. bar\n"
144func OrderedList(items []string) string {
145 var sb strings.Builder
146 for i, item := range items {
147 lines := strings.Split(item, "\n")
148 sb.WriteString(strconv.Itoa(i+1) + ". " + lines[0] + "\n")
149 for _, line := range lines[1:] {
150 sb.WriteString(" " + line + "\n")
151 }
152 }
153 return sb.String()
154}
155
156// TodoList returns a list of todo items with checkboxes for markdown.
157// Example: TodoList([]string{"foo", "bar\nmore bar"}, []bool{true, false}) => "- [x] foo\n- [ ] bar\n more bar\n"
158func TodoList(items []string, done []bool) string {
159 var sb strings.Builder
160 for i, item := range items {
161 sb.WriteString(TodoItem(item, done[i]))
162 }
163 return sb.String()
164}
165
166// TodoItem returns a todo item with checkbox for markdown.
167// Example: TodoItem("foo", true) => "- [x] foo\n"
168func TodoItem(item string, done bool) string {
169 var sb strings.Builder
170 checkbox := " "
171 if done {
172 checkbox = "x"
173 }
174 lines := strings.Split(item, "\n")
175 sb.WriteString("- [" + checkbox + "] " + lines[0] + "\n")
176 for _, line := range lines[1:] {
177 sb.WriteString(" " + line + "\n")
178 }
179 return sb.String()
180}
181
182// Nested prefixes each line with a given prefix, enabling nested lists.
183// Example: Nested("- foo\n- bar", " ") => " - foo\n - bar\n"
184func Nested(content, prefix string) string {
185 lines := strings.Split(content, "\n")
186 for i := range lines {
187 //gnovet:ignore trimspace-as-validity this indents content lines, it does not validate a stored field: a zero-width-only line is content and must be indented
188 if strings.TrimSpace(lines[i]) != "" {
189 lines[i] = prefix + lines[i]
190 }
191 }
192 return strings.Join(lines, "\n")
193}
194
195// Blockquote returns the text as a CommonMark blockquote.
196// Example: Blockquote("foo\nbar") => "\n> foo\n> bar\n\n"
197//
198// Delegates to sanitize.Blockquote, which cleans the content (bidi-strip,
199// CR/CRLF/U+2028/U+2029/NEL line-ending normalize, LRD strip, ref-link
200// escape, block-marker escape, fence auto-close), line-prefixes each
201// line with "> ", and wraps with "\n" / "\n\n" so the quote opens
202// cleanly and cannot pull appended chrome into the quote via CM §5.2
203// lazy continuation. Callers do NOT need to pre-wrap the input.
204func Blockquote(text string) string {
205 return sanitize.Blockquote(text)
206}
207
208// InlineCode wraps the given text as a CommonMark inline code span.
209// Example: InlineCode("foo") => "`foo`"
210//
211// Delegates to sanitize.InlineCode, which cleans the input (bidi-strip,
212// CR/CRLF + NEL + U+2028/U+2029 folded to single space, NUL→U+FFFD)
213// and picks a backtick-run length that outscans any internal backticks.
214// Callers do NOT need to pre-wrap the input.
215func InlineCode(code string) string {
216 return sanitize.InlineCode(code)
217}
218
219// CodeBlock creates a markdown code block.
220// Example: CodeBlock("foo") => "```\nfoo\n```"
221//
222// Delegates to sanitize.CodeBlock, which cleans the content (bidi-strip,
223// CR/CRLF normalize, NEL/U+2028/U+2029 fold, NUL→U+FFFD) and picks a
224// fence wide enough to outscan any backticks in content. Callers do NOT
225// need to pre-wrap content with another sanitize helper.
226func CodeBlock(content string) string {
227 return sanitize.CodeBlock(content)
228}
229
230// LanguageCodeBlock creates a markdown code block with language-specific syntax highlighting.
231// Example: LanguageCodeBlock("go", "foo") => "```go\nfoo\n```"
232//
233// Delegates to sanitize.LanguageCodeBlock, which validates the language
234// tag (charset ^[a-zA-Z0-9_+-]{1,32}$, falling back to a tagless fence
235// if invalid) and cleans the content as CodeBlock does. Callers do NOT
236// need to pre-wrap either argument.
237func LanguageCodeBlock(language, content string) string {
238 return sanitize.LanguageCodeBlock(language, content)
239}
240
241// HorizontalRule returns a horizontal rule for markdown.
242// Example: HorizontalRule() => "---\n"
243func HorizontalRule() string {
244 return "---\n"
245}
246
247// Link returns a hyperlink for markdown.
248// Example: Link("foo", "http://example.com") => "[foo](http://example.com)"
249//
250// The text and url args are sanitized internally — text via
251// sanitize.InlineText, url via sanitize.URL (allowlists http/https/
252// mailto/relative/fragment; rejects javascript:, data:, blob:, etc.).
253// Callers do NOT need to pre-wrap either argument; double-wrapping is
254// a bug (the inline-text escaper is non-idempotent).
255//
256// If the URL fails the scheme allowlist, the href is rendered empty —
257// the link becomes inert rather than carrying a malicious destination.
258func Link(text, url string) string {
259 return "[" + sanitize.InlineText(text) + "](" + sanitize.URL(url) + ")"
260}
261
262// UserLink returns a user profile link for markdown.
263// Example: UserLink("moul") => "[@moul](/u/moul)"
264// Example: UserLink("g1blah...") => "[g1blah...](/u/g1blah...)"
265//
266// Validates the user identifier — if it matches the gno bech32 address
267// pattern (g1...), produces an address-style link; otherwise tries the
268// r/sys/users-charset username and produces an @-style link. Returns
269// "" if the identifier matches neither — callers should treat "" as
270// "skip the user mention" rather than emit a broken link.
271func UserLink(user string) string {
272 if addr := sanitize.BechString(user, "g"); addr != "" {
273 return "[" + addr + "](/u/" + addr + ")"
274 }
275 if name := sanitize.UserName(user); name != "" {
276 return "[@" + name + "](/u/" + name + ")"
277 }
278 return ""
279}
280
281// InlineImageWithLink creates an inline image wrapped in a hyperlink for markdown.
282// Example: InlineImageWithLink("alt text", "image-url", "link-url") => "[](link-url)"
283//
284// altText and imageUrl are sanitized via Image (sanitize.InlineText +
285// sanitize.ImageURL); linkUrl is sanitized via sanitize.URL. Callers
286// do NOT need to pre-wrap any argument.
287func InlineImageWithLink(altText, imageUrl, linkUrl string) string {
288 return "[" + Image(altText, imageUrl) + "](" + sanitize.URL(linkUrl) + ")"
289}
290
291// Image returns an image for markdown.
292// Example: Image("foo", "http://example.com") => ""
293//
294// altText is sanitized via sanitize.InlineText, url via
295// sanitize.ImageURL (allowlists http/https/relative + data:image/*;
296// rejects mailto:, javascript:, data:text/html, etc.). Callers do NOT
297// need to pre-wrap either argument.
298func Image(altText, url string) string {
299 return " + ")"
300}
301
302// FootnoteDefinition emits a GFM footnote definition — `[^name]: body` —
303// for a footnote that is referenced elsewhere in the document by
304// `[^name]`.
305//
306// Example: FootnoteDefinition("note1", "Long form of the citation.")
307// renders as:
308//
309// [^note1]:
310// Long form of the citation.
311//
312// The `name` is validated as a FootnoteLabel (^[A-Za-z0-9_-]{1,64}$);
313// `text` is user-supplied multi-paragraph prose, sanitized via Block.
314// An invalid name or empty body returns "".
315//
316// Delegates to sanitize.FootnoteDefinition. Callers do NOT need to
317// pre-wrap either argument.
318func FootnoteDefinition(name, text string) string {
319 return sanitize.FootnoteDefinition(name, text)
320}
321
322// LinkReferenceDefinition emits a CommonMark link reference definition
323// (CM §4.7) — `[label]: url "title"` — for a reference link that is
324// invoked elsewhere by `[text][label]` or by the shortcut form
325// `[label]`.
326//
327// Example: LinkReferenceDefinition("r/docs/help", "/r/docs/help", "")
328// renders as:
329//
330// [r/docs/help]: /r/docs/help
331//
332// The `label` is validated as a FootnoteLabel (^[A-Za-z0-9_-]{1,64}$);
333// `url` is sanitized via URL (allowlist); `title` is sanitized via
334// LinkTitle. An invalid label or rejected URL returns "".
335//
336// Realms should choose a namespaced label using dashes
337// (e.g. `r-myrealm-help`) so that shortcut-reference invocations from
338// user content can't collide with bare words a user is likely to write.
339// `/` is not in the FootnoteLabel charset.
340//
341// Delegates to sanitize.LinkReferenceDefinition. Callers do NOT need to
342// pre-wrap any argument.
343func LinkReferenceDefinition(label, url, title string) string {
344 return sanitize.LinkReferenceDefinition(label, url, title)
345}
346
347// Paragraph wraps the given text in a Markdown paragraph.
348// Example: Paragraph("foo") => "foo\n"
349func Paragraph(content string) string {
350 return content + "\n\n"
351}
352
353// CollapsibleSection creates a collapsible section for markdown using
354// HTML <details> and <summary> tags.
355// Example:
356// CollapsibleSection("Click to expand", "Hidden content")
357// =>
358// <details><summary>Click to expand</summary>
359//
360// Hidden content
361// </details>
362//
363// The title argument is sanitized via sanitize.HTMLEscape (it lands in
364// an HTML element body inside <summary>, not a markdown context — so
365// HTML entity escaping is the correct policy, not markdown backslash
366// escaping). The content argument is passed through unchanged because
367// <details> with a blank-line-separated body allows markdown inside
368// (CM §4.6); callers must pre-wrap content with sanitize.Block if it
369// derives from user input.
370func CollapsibleSection(title, content string) string {
371 return "<details><summary>" + sanitize.HTMLEscape(title) + "</summary>\n\n" + content + "\n</details>\n"
372}
373
374// EscapeURL escapes characters in a URL for use in markdown link syntax.
375//
376// Deprecated: use sanitize.URL (for link href) or sanitize.ImageURL
377// (for image src) directly. EscapeURL previously only percent-encoded
378// ( and ) and did not validate the URL scheme — a security footgun
379// (EscapeURL("javascript:alert(1)") returned a working XSS payload).
380// It now delegates to sanitize.URL, which allowlists schemes (rejects
381// javascript:, data:text/html, vbscript:, blob:, etc.) and
382// percent-encodes all unsafe bytes (including the ( ) this function
383// handled). The other helpers in this package (Link, UserLink, Image,
384// InlineImageWithLink) sanitize their URL args internally now, so you
385// rarely need to call any URL-escape helper directly.
386//
387// Behavior change vs. the original implementation: invalid schemes
388// now return "" instead of passing through with ( ) escaped. Non-ASCII
389// bytes get percent-encoded to standard RFC 3986 wire form instead of
390// passing through as raw UTF-8.
391func EscapeURL(url string) string {
392 return sanitize.URL(url)
393}
394
395// EscapeText escapes special Markdown characters in regular text for
396// use in inline contexts.
397//
398// Deprecated: use sanitize.InlineText directly. EscapeText was
399// INCOMPLETE — it missed \, #, <, & and did not strip bidi/zero-width
400// characters, replace NUL with U+FFFD, or normalize line endings.
401// User input could inject backslash escapes (\* cancels neighboring
402// escapes), autolinks (<https://x>), raw HTML (<script>), HTML entity
403// references (&), and bidi spoofing (RLO before an address). It
404// now delegates to sanitize.InlineText. The other helpers in this
405// package (Link, UserLink, Image, InlineImageWithLink, CollapsibleSection)
406// sanitize their text args internally now, so you rarely need to call
407// any inline-text-escape helper directly.
408//
409// Behavior change vs. the original implementation: \, #, <, & are now
410// escaped; | is no longer escaped (the original was over-escaping —
411// outside GFM table-cell context, | is markdown-inert; for table
412// cells use sanitize.TableCell which adds the | escape on top of the
413// inline-text set). Bidi controls are stripped, NUL becomes U+FFFD,
414// and line endings normalize.
415func EscapeText(text string) string {
416 return sanitize.InlineText(text)
417}
418
419// Columns returns a formatted row of columns using the Gno syntax.
420// If you want a specific number of columns per row (<=4), use ColumnsN.
421// Check /r/docs/markdown#columns for more info.
422// If padded=true & the final <gno-columns> tag is missing column content, an empty
423// column element will be placed to keep the cols per row constant.
424// Padding works only with colsPerRow > 0.
425//
426// Example:
427//
428// Columns([]string{"A", "B"}, false)
429// // Returns:
430// // <gno-columns>
431// // A
432// // <gno-columns-sep>
433// // B
434// // </gno-columns>
435func Columns(contentByColumn []string, padded bool) string {
436 if len(contentByColumn) == 0 {
437 return ""
438 }
439 maxCols := 4
440 if padded && len(contentByColumn)%maxCols != 0 {
441 missing := maxCols - len(contentByColumn)%maxCols
442 contentByColumn = append(contentByColumn, make([]string, missing)...)
443 }
444
445 var sb strings.Builder
446 sb.WriteString("<gno-columns>\n")
447
448 for i, column := range contentByColumn {
449 if i > 0 {
450 sb.WriteString("<gno-columns-sep>\n")
451 }
452 sb.WriteString(column + "\n")
453 }
454
455 sb.WriteString("</gno-columns>\n")
456 return sb.String()
457}
458
459const maxColumnsPerRow = 4
460
461// ColumnsN splits content into multiple rows of N columns each and formats them.
462// If colsPerRow <= 0, all items are placed in one <gno-columns> block.
463// If padded=true & the final <gno-columns> tag is missing column content, an empty
464// column element will be placed to keep the cols per row constant.
465// Padding works only with colsPerRow > 0.
466// Note: On standard-size screens, gnoweb handles a max of 4 cols per row.
467//
468// Example:
469//
470// ColumnsN([]string{"A", "B", "C"}, 2, false)
471// // Returns:
472// // <gno-columns>
473// // A
474// // <gno-columns-sep>
475// // B
476// // </gno-columns>
477// // <gno-columns>
478// // C
479// // </gno-columns>
480func ColumnsN(content []string, colsPerRow int, padded bool) string {
481 if len(content) == 0 {
482 return ""
483 }
484 if colsPerRow <= 0 {
485 return Columns(content, padded)
486 }
487
488 var sb strings.Builder
489 // Case 2: Multiple blocks with max 4 columns
490 for i := 0; i < len(content); i += colsPerRow {
491 end := i + colsPerRow
492 if end > len(content) {
493 end = len(content)
494 }
495 row := content[i:end]
496
497 // Add padding if needed
498 if padded && len(row) < colsPerRow {
499 row = append(row, make([]string, colsPerRow-len(row))...)
500 }
501
502 sb.WriteString(Columns(row, false))
503 }
504 return sb.String()
505}