|
@@ -0,0 +1,238 @@
|
|
|
+// Command md2html converts Markdown to HTML.
|
|
|
+//
|
|
|
+// It is tailored for converting the Markdown sources of the Elvish website
|
|
|
+// (https://elv.sh) to HTML. Based on [md.HTMLCodec], it adds the following
|
|
|
+// features:
|
|
|
+//
|
|
|
+// - Autogenerated ID for each heading
|
|
|
+//
|
|
|
+// - Self link for each heading
|
|
|
+//
|
|
|
+// The following features can be turned on per Markdown file by specifying them
|
|
|
+// in an HTML comment before any text (for example <!-- toc number-sections -->
|
|
|
+// turns on both):
|
|
|
+//
|
|
|
+// - toc: Generate a table of content
|
|
|
+//
|
|
|
+// - number-sections: Generate section numbers for headings
|
|
|
+package main
|
|
|
+
|
|
|
+import (
|
|
|
+ "fmt"
|
|
|
+ "html"
|
|
|
+ "io"
|
|
|
+ "log"
|
|
|
+ "os"
|
|
|
+ "regexp"
|
|
|
+ "strings"
|
|
|
+
|
|
|
+ "src.elv.sh/pkg/md"
|
|
|
+)
|
|
|
+
|
|
|
+func main() {
|
|
|
+ markdown, err := io.ReadAll(os.Stdin)
|
|
|
+ if err != nil {
|
|
|
+ log.Fatal(err)
|
|
|
+ }
|
|
|
+ codec := &codec{}
|
|
|
+ md.Render(string(markdown), md.SmartPunctsCodec{Inner: codec})
|
|
|
+ os.Stdout.WriteString(codec.String())
|
|
|
+}
|
|
|
+
|
|
|
+type codec struct {
|
|
|
+ md.HTMLCodec
|
|
|
+ // Extensions
|
|
|
+ numberSections, toc bool
|
|
|
+ // Components of the current section number. Populated if numberSections or
|
|
|
+ // toc is true (used for maintaining the sections array in the latter case).
|
|
|
+ sectionNumbers []int
|
|
|
+ // Tree of sections to be used in the table of content. Populated if toc is
|
|
|
+ // true. The root node is a dummy node.
|
|
|
+ sectionRoot section
|
|
|
+}
|
|
|
+
|
|
|
+type section struct {
|
|
|
+ title string
|
|
|
+ id string
|
|
|
+ children []section
|
|
|
+}
|
|
|
+
|
|
|
+var (
|
|
|
+ numberSectionsRegexp = regexp.MustCompile(`\bnumber-sections\b`)
|
|
|
+ tocRegexp = regexp.MustCompile(`\btoc\b`)
|
|
|
+)
|
|
|
+
|
|
|
+func (c *codec) Do(op md.Op) {
|
|
|
+ switch op.Type {
|
|
|
+ case md.OpHeading:
|
|
|
+ id := ""
|
|
|
+ // Only support #id since that's the only thing used in Elvish's
|
|
|
+ // Markdown right now. More can be added if needed.
|
|
|
+ if info := op.Info; info != "" && op.Info[0] == '#' {
|
|
|
+ id = op.Info[1:]
|
|
|
+ } else {
|
|
|
+ // Generate an ID using the inline text content converted to lower
|
|
|
+ // case.
|
|
|
+ id = strings.ToLower(plainTextOfInlineContent(op.Content))
|
|
|
+ }
|
|
|
+ idHTML := html.EscapeString(processHTMLID(id))
|
|
|
+
|
|
|
+ level := op.Number
|
|
|
+ // An empty onclick handler is needed for :hover to work on mobile:
|
|
|
+ // https://stackoverflow.com/a/25673064/566659
|
|
|
+ fmt.Fprintf(c, `<h%d onclick="" id="%s">`, level, idHTML)
|
|
|
+
|
|
|
+ // Render the content separately first; this may be used in the ToC too.
|
|
|
+ var sb strings.Builder
|
|
|
+ md.RenderInlineContentToHTML(&sb, op.Content)
|
|
|
+ titleHTML := sb.String()
|
|
|
+
|
|
|
+ // Number the section.
|
|
|
+ if c.numberSections || c.toc {
|
|
|
+ if level < len(c.sectionNumbers) {
|
|
|
+ // When going from a higher section level to a lower one,
|
|
|
+ // discard higher-level numbers. Discard higher-level section
|
|
|
+ // numbers. For example, when going from a #### to a #, only
|
|
|
+ // keep the first section number.
|
|
|
+ c.sectionNumbers = c.sectionNumbers[:level]
|
|
|
+ }
|
|
|
+ if level == len(c.sectionNumbers) {
|
|
|
+ c.sectionNumbers[level-1]++
|
|
|
+ } else {
|
|
|
+ // We are going from a lower section level to a higher one (e.g.
|
|
|
+ // # to ##), possibly with missing levels (e.g. # to ###).
|
|
|
+ // Populate all with 1.
|
|
|
+ for level > len(c.sectionNumbers) {
|
|
|
+ c.sectionNumbers = append(c.sectionNumbers, 1)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if c.numberSections {
|
|
|
+ titleHTML = sectionNumberPrefix(c.sectionNumbers) + titleHTML
|
|
|
+ }
|
|
|
+ if c.toc {
|
|
|
+ // The section numbers identify a path in the section tree.
|
|
|
+ p := &c.sectionRoot
|
|
|
+ for _, num := range c.sectionNumbers {
|
|
|
+ idx := num - 1
|
|
|
+ if idx == len(p.children) {
|
|
|
+ p.children = append(p.children, section{})
|
|
|
+ }
|
|
|
+ p = &p.children[idx]
|
|
|
+ }
|
|
|
+ p.id = idHTML
|
|
|
+ p.title = titleHTML
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ c.WriteString(titleHTML)
|
|
|
+
|
|
|
+ // Add self link
|
|
|
+ fmt.Fprintf(c,
|
|
|
+ `<a href="#%s" class="anchor icon-link" aria-hidden="true"></a>`, idHTML)
|
|
|
+
|
|
|
+ fmt.Fprintf(c, "</h%d>\n", op.Number)
|
|
|
+ case md.OpHTMLBlock:
|
|
|
+ if c.Len() == 0 && strings.HasPrefix(op.Lines[0], "<!--") {
|
|
|
+ // Look for options.
|
|
|
+ for _, line := range op.Lines {
|
|
|
+ if numberSectionsRegexp.MatchString(line) {
|
|
|
+ c.numberSections = true
|
|
|
+ }
|
|
|
+ if tocRegexp.MatchString(line) {
|
|
|
+ c.toc = true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ c.HTMLCodec.Do(op)
|
|
|
+ default:
|
|
|
+ c.HTMLCodec.Do(op)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func sectionNumberPrefix(nums []int) string {
|
|
|
+ var sb strings.Builder
|
|
|
+ for _, num := range nums {
|
|
|
+ fmt.Fprintf(&sb, "%d.", num)
|
|
|
+ }
|
|
|
+ sb.WriteByte(' ')
|
|
|
+ return sb.String()
|
|
|
+}
|
|
|
+
|
|
|
+func plainTextOfInlineContent(ops []md.InlineOp) string {
|
|
|
+ var sb strings.Builder
|
|
|
+ for _, op := range ops {
|
|
|
+ sb.WriteString(op.String())
|
|
|
+ }
|
|
|
+ return sb.String()
|
|
|
+}
|
|
|
+
|
|
|
+var whitespaceRun = regexp.MustCompile(`\s+`)
|
|
|
+
|
|
|
+func processHTMLID(s string) string {
|
|
|
+ // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id
|
|
|
+ // Only whitespaces are not allowed in ID; place them with "-".
|
|
|
+ return whitespaceRun.ReplaceAllLiteralString(s, "-")
|
|
|
+}
|
|
|
+
|
|
|
+const tocBefore = `
|
|
|
+<div id="pandoc-toc-wrapper">
|
|
|
+ <p>Table of Content: <span id="pandoc-toc-toggle-wrapper"></span></p>
|
|
|
+ <div id="pandoc-toc">
|
|
|
+`
|
|
|
+
|
|
|
+const tocAfter = `
|
|
|
+ </div>
|
|
|
+ <script>
|
|
|
+ (function() {
|
|
|
+ var shown = true,
|
|
|
+ tocToggleWrapper = document.getElementById('pandoc-toc-toggle-wrapper'),
|
|
|
+ tocList = document.getElementById('pandoc-toc');
|
|
|
+ var tocToggle = document.createElement('a');
|
|
|
+ tocToggle.innerText = "[Hide]";
|
|
|
+ tocToggle.href = "";
|
|
|
+ tocToggleWrapper.appendChild(tocToggle);
|
|
|
+ tocToggle.onclick = function(ev) {
|
|
|
+ shown = !shown;
|
|
|
+ if (shown) {
|
|
|
+ tocToggle.innerText = "[Hide]";
|
|
|
+ tocList.className = "";
|
|
|
+ } else {
|
|
|
+ tocToggle.innerText = "[Show]";
|
|
|
+ tocList.className = "no-display";
|
|
|
+ }
|
|
|
+ ev.preventDefault();
|
|
|
+ };
|
|
|
+ })();
|
|
|
+ </script>
|
|
|
+</div>
|
|
|
+`
|
|
|
+
|
|
|
+func (c *codec) String() string {
|
|
|
+ if !c.toc {
|
|
|
+ return c.HTMLCodec.String()
|
|
|
+ }
|
|
|
+ var sb strings.Builder
|
|
|
+ sb.WriteString(tocBefore)
|
|
|
+ sb.WriteString("<ul>\n")
|
|
|
+ for _, section := range c.sectionRoot.children {
|
|
|
+ writeSection(&sb, section)
|
|
|
+ }
|
|
|
+ sb.WriteString("</ul>\n")
|
|
|
+ sb.WriteString(tocAfter)
|
|
|
+
|
|
|
+ sb.WriteString(c.HTMLCodec.String())
|
|
|
+ return sb.String()
|
|
|
+}
|
|
|
+
|
|
|
+func writeSection(sb *strings.Builder, s section) {
|
|
|
+ fmt.Fprintf(sb, `<li><a href="#%s">%s</a>`, s.id, s.title)
|
|
|
+ if len(s.children) > 0 {
|
|
|
+ sb.WriteString("\n<ul>\n")
|
|
|
+ for _, child := range s.children {
|
|
|
+ writeSection(sb, child)
|
|
|
+ }
|
|
|
+ sb.WriteString("</ul>\n")
|
|
|
+ }
|
|
|
+ sb.WriteString("</li>\n")
|
|
|
+}
|