github.com/phsym/gomarkdoc@v0.5.4/format/formatcore/base.go (about)

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