Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

md.gno

17.83 Kb · 504 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		if strings.TrimSpace(lines[i]) != "" {
188			lines[i] = prefix + lines[i]
189		}
190	}
191	return strings.Join(lines, "\n")
192}
193
194// Blockquote returns the text as a CommonMark blockquote.
195// Example: Blockquote("foo\nbar") => "\n> foo\n> bar\n\n"
196//
197// Delegates to sanitize.Blockquote, which cleans the content (bidi-strip,
198// CR/CRLF/U+2028/U+2029/NEL line-ending normalize, LRD strip, ref-link
199// escape, block-marker escape, fence auto-close), line-prefixes each
200// line with "> ", and wraps with "\n" / "\n\n" so the quote opens
201// cleanly and cannot pull appended chrome into the quote via CM §5.2
202// lazy continuation. Callers do NOT need to pre-wrap the input.
203func Blockquote(text string) string {
204	return sanitize.Blockquote(text)
205}
206
207// InlineCode wraps the given text as a CommonMark inline code span.
208// Example: InlineCode("foo") => "`foo`"
209//
210// Delegates to sanitize.InlineCode, which cleans the input (bidi-strip,
211// CR/CRLF + NEL + U+2028/U+2029 folded to single space, NUL→U+FFFD)
212// and picks a backtick-run length that outscans any internal backticks.
213// Callers do NOT need to pre-wrap the input.
214func InlineCode(code string) string {
215	return sanitize.InlineCode(code)
216}
217
218// CodeBlock creates a markdown code block.
219// Example: CodeBlock("foo") => "```\nfoo\n```"
220//
221// Delegates to sanitize.CodeBlock, which cleans the content (bidi-strip,
222// CR/CRLF normalize, NEL/U+2028/U+2029 fold, NUL→U+FFFD) and picks a
223// fence wide enough to outscan any backticks in content. Callers do NOT
224// need to pre-wrap content with another sanitize helper.
225func CodeBlock(content string) string {
226	return sanitize.CodeBlock(content)
227}
228
229// LanguageCodeBlock creates a markdown code block with language-specific syntax highlighting.
230// Example: LanguageCodeBlock("go", "foo") => "```go\nfoo\n```"
231//
232// Delegates to sanitize.LanguageCodeBlock, which validates the language
233// tag (charset ^[a-zA-Z0-9_+-]{1,32}$, falling back to a tagless fence
234// if invalid) and cleans the content as CodeBlock does. Callers do NOT
235// need to pre-wrap either argument.
236func LanguageCodeBlock(language, content string) string {
237	return sanitize.LanguageCodeBlock(language, content)
238}
239
240// HorizontalRule returns a horizontal rule for markdown.
241// Example: HorizontalRule() => "---\n"
242func HorizontalRule() string {
243	return "---\n"
244}
245
246// Link returns a hyperlink for markdown.
247// Example: Link("foo", "http://example.com") => "[foo](http://example.com)"
248//
249// The text and url args are sanitized internally — text via
250// sanitize.InlineText, url via sanitize.URL (allowlists http/https/
251// mailto/relative/fragment; rejects javascript:, data:, blob:, etc.).
252// Callers do NOT need to pre-wrap either argument; double-wrapping is
253// a bug (the inline-text escaper is non-idempotent).
254//
255// If the URL fails the scheme allowlist, the href is rendered empty —
256// the link becomes inert rather than carrying a malicious destination.
257func Link(text, url string) string {
258	return "[" + sanitize.InlineText(text) + "](" + sanitize.URL(url) + ")"
259}
260
261// UserLink returns a user profile link for markdown.
262// Example: UserLink("moul") => "[@moul](/u/moul)"
263// Example: UserLink("g1blah...") => "[g1blah...](/u/g1blah...)"
264//
265// Validates the user identifier — if it matches the gno bech32 address
266// pattern (g1...), produces an address-style link; otherwise tries the
267// r/sys/users-charset username and produces an @-style link. Returns
268// "" if the identifier matches neither — callers should treat "" as
269// "skip the user mention" rather than emit a broken link.
270func UserLink(user string) string {
271	if addr := sanitize.BechString(user, "g"); addr != "" {
272		return "[" + addr + "](/u/" + addr + ")"
273	}
274	if name := sanitize.UserName(user); name != "" {
275		return "[@" + name + "](/u/" + name + ")"
276	}
277	return ""
278}
279
280// InlineImageWithLink creates an inline image wrapped in a hyperlink for markdown.
281// Example: InlineImageWithLink("alt text", "image-url", "link-url") => "[![alt text](image-url)](link-url)"
282//
283// altText and imageUrl are sanitized via Image (sanitize.InlineText +
284// sanitize.ImageURL); linkUrl is sanitized via sanitize.URL. Callers
285// do NOT need to pre-wrap any argument.
286func InlineImageWithLink(altText, imageUrl, linkUrl string) string {
287	return "[" + Image(altText, imageUrl) + "](" + sanitize.URL(linkUrl) + ")"
288}
289
290// Image returns an image for markdown.
291// Example: Image("foo", "http://example.com") => "![foo](http://example.com)"
292//
293// altText is sanitized via sanitize.InlineText, url via
294// sanitize.ImageURL (allowlists http/https/relative + data:image/*;
295// rejects mailto:, javascript:, data:text/html, etc.). Callers do NOT
296// need to pre-wrap either argument.
297func Image(altText, url string) string {
298	return "![" + sanitize.InlineText(altText) + "](" + sanitize.ImageURL(url) + ")"
299}
300
301// FootnoteDefinition emits a GFM footnote definition — `[^name]: body` —
302// for a footnote that is referenced elsewhere in the document by
303// `[^name]`.
304//
305// Example: FootnoteDefinition("note1", "Long form of the citation.")
306// renders as:
307//
308//	[^note1]:
309//	    Long form of the citation.
310//
311// The `name` is validated as a FootnoteLabel (^[A-Za-z0-9_-]{1,64}$);
312// `text` is user-supplied multi-paragraph prose, sanitized via Block.
313// An invalid name or empty body returns "".
314//
315// Delegates to sanitize.FootnoteDefinition. Callers do NOT need to
316// pre-wrap either argument.
317func FootnoteDefinition(name, text string) string {
318	return sanitize.FootnoteDefinition(name, text)
319}
320
321// LinkReferenceDefinition emits a CommonMark link reference definition
322// (CM §4.7) — `[label]: url "title"` — for a reference link that is
323// invoked elsewhere by `[text][label]` or by the shortcut form
324// `[label]`.
325//
326// Example: LinkReferenceDefinition("r/docs/help", "/r/docs/help", "")
327// renders as:
328//
329//	[r/docs/help]: /r/docs/help
330//
331// The `label` is validated as a FootnoteLabel (^[A-Za-z0-9_-]{1,64}$);
332// `url` is sanitized via URL (allowlist); `title` is sanitized via
333// LinkTitle. An invalid label or rejected URL returns "".
334//
335// Realms should choose a namespaced label using dashes
336// (e.g. `r-myrealm-help`) so that shortcut-reference invocations from
337// user content can't collide with bare words a user is likely to write.
338// `/` is not in the FootnoteLabel charset.
339//
340// Delegates to sanitize.LinkReferenceDefinition. Callers do NOT need to
341// pre-wrap any argument.
342func LinkReferenceDefinition(label, url, title string) string {
343	return sanitize.LinkReferenceDefinition(label, url, title)
344}
345
346// Paragraph wraps the given text in a Markdown paragraph.
347// Example: Paragraph("foo") => "foo\n"
348func Paragraph(content string) string {
349	return content + "\n\n"
350}
351
352// CollapsibleSection creates a collapsible section for markdown using
353// HTML <details> and <summary> tags.
354// Example:
355// CollapsibleSection("Click to expand", "Hidden content")
356// =>
357// <details><summary>Click to expand</summary>
358//
359// Hidden content
360// </details>
361//
362// The title argument is sanitized via sanitize.HTMLEscape (it lands in
363// an HTML element body inside <summary>, not a markdown context — so
364// HTML entity escaping is the correct policy, not markdown backslash
365// escaping). The content argument is passed through unchanged because
366// <details> with a blank-line-separated body allows markdown inside
367// (CM §4.6); callers must pre-wrap content with sanitize.Block if it
368// derives from user input.
369func CollapsibleSection(title, content string) string {
370	return "<details><summary>" + sanitize.HTMLEscape(title) + "</summary>\n\n" + content + "\n</details>\n"
371}
372
373// EscapeURL escapes characters in a URL for use in markdown link syntax.
374//
375// Deprecated: use sanitize.URL (for link href) or sanitize.ImageURL
376// (for image src) directly. EscapeURL previously only percent-encoded
377// ( and ) and did not validate the URL scheme — a security footgun
378// (EscapeURL("javascript:alert(1)") returned a working XSS payload).
379// It now delegates to sanitize.URL, which allowlists schemes (rejects
380// javascript:, data:text/html, vbscript:, blob:, etc.) and
381// percent-encodes all unsafe bytes (including the ( ) this function
382// handled). The other helpers in this package (Link, UserLink, Image,
383// InlineImageWithLink) sanitize their URL args internally now, so you
384// rarely need to call any URL-escape helper directly.
385//
386// Behavior change vs. the original implementation: invalid schemes
387// now return "" instead of passing through with ( ) escaped. Non-ASCII
388// bytes get percent-encoded to standard RFC 3986 wire form instead of
389// passing through as raw UTF-8.
390func EscapeURL(url string) string {
391	return sanitize.URL(url)
392}
393
394// EscapeText escapes special Markdown characters in regular text for
395// use in inline contexts.
396//
397// Deprecated: use sanitize.InlineText directly. EscapeText was
398// INCOMPLETE — it missed \, #, <, & and did not strip bidi/zero-width
399// characters, replace NUL with U+FFFD, or normalize line endings.
400// User input could inject backslash escapes (\* cancels neighboring
401// escapes), autolinks (<https://x>), raw HTML (<script>), HTML entity
402// references (&amp;), and bidi spoofing (RLO before an address). It
403// now delegates to sanitize.InlineText. The other helpers in this
404// package (Link, UserLink, Image, InlineImageWithLink, CollapsibleSection)
405// sanitize their text args internally now, so you rarely need to call
406// any inline-text-escape helper directly.
407//
408// Behavior change vs. the original implementation: \, #, <, & are now
409// escaped; | is no longer escaped (the original was over-escaping —
410// outside GFM table-cell context, | is markdown-inert; for table
411// cells use sanitize.TableCell which adds the | escape on top of the
412// inline-text set). Bidi controls are stripped, NUL becomes U+FFFD,
413// and line endings normalize.
414func EscapeText(text string) string {
415	return sanitize.InlineText(text)
416}
417
418// Columns returns a formatted row of columns using the Gno syntax.
419// If you want a specific number of columns per row (<=4), use ColumnsN.
420// Check /r/docs/markdown#columns for more info.
421// If padded=true & the final <gno-columns> tag is missing column content, an empty
422// column element will be placed to keep the cols per row constant.
423// Padding works only with colsPerRow > 0.
424//
425// Example:
426//
427//	Columns([]string{"A", "B"}, false)
428//	// Returns:
429//	// <gno-columns>
430//	// A
431//	// <gno-columns-sep>
432//	// B
433//	// </gno-columns>
434func Columns(contentByColumn []string, padded bool) string {
435	if len(contentByColumn) == 0 {
436		return ""
437	}
438	maxCols := 4
439	if padded && len(contentByColumn)%maxCols != 0 {
440		missing := maxCols - len(contentByColumn)%maxCols
441		contentByColumn = append(contentByColumn, make([]string, missing)...)
442	}
443
444	var sb strings.Builder
445	sb.WriteString("<gno-columns>\n")
446
447	for i, column := range contentByColumn {
448		if i > 0 {
449			sb.WriteString("<gno-columns-sep>\n")
450		}
451		sb.WriteString(column + "\n")
452	}
453
454	sb.WriteString("</gno-columns>\n")
455	return sb.String()
456}
457
458const maxColumnsPerRow = 4
459
460// ColumnsN splits content into multiple rows of N columns each and formats them.
461// If colsPerRow <= 0, all items are placed in one <gno-columns> block.
462// If padded=true & the final <gno-columns> tag is missing column content, an empty
463// column element will be placed to keep the cols per row constant.
464// Padding works only with colsPerRow > 0.
465// Note: On standard-size screens, gnoweb handles a max of 4 cols per row.
466//
467// Example:
468//
469//	ColumnsN([]string{"A", "B", "C"}, 2, false)
470//	// Returns:
471//	// <gno-columns>
472//	// A
473//	// <gno-columns-sep>
474//	// B
475//	// </gno-columns>
476//	// <gno-columns>
477//	// C
478//	// </gno-columns>
479func ColumnsN(content []string, colsPerRow int, padded bool) string {
480	if len(content) == 0 {
481		return ""
482	}
483	if colsPerRow <= 0 {
484		return Columns(content, padded)
485	}
486
487	var sb strings.Builder
488	// Case 2: Multiple blocks with max 4 columns
489	for i := 0; i < len(content); i += colsPerRow {
490		end := i + colsPerRow
491		if end > len(content) {
492			end = len(content)
493		}
494		row := content[i:end]
495
496		// Add padding if needed
497		if padded && len(row) < colsPerRow {
498			row = append(row, make([]string, colsPerRow-len(row))...)
499		}
500
501		sb.WriteString(Columns(row, false))
502	}
503	return sb.String()
504}