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  }