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.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") => "[![alt text](image-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") => "![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 "![" + sanitize.InlineText(altText) + "](" + sanitize.ImageURL(url) + ")"
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 (&amp;), 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}