Skip to content
quarry Artifact [3098ad914d]
Login

Artifact [3098ad914d]

Artifact 3098ad914d3052bbf2230304c128a96ef9c4b500785478d140a7ee99f695a827:


package markup

import (
	"regexp"
	"strings"
	"unicode/utf8"

	gast "github.com/yuin/goldmark/ast"
	east "github.com/yuin/goldmark/extension/ast"
	"github.com/yuin/goldmark/parser"
	"github.com/yuin/goldmark/renderer"
	"github.com/yuin/goldmark/renderer/html"
	"github.com/yuin/goldmark/text"
	"github.com/yuin/goldmark/util"
)

// The rendering hooks the stylesheet keys on: task-list state, table
// column hints and GitHub-style alerts. All of them are computed here,
// at render time, and reach the page as classes the sanitizer admits by
// exact name.

// taskLists marks each task item with its state and a list made only of
// task items with md-tasks, so the stylesheet can drop the bullet that
// would otherwise sit beside the checkbox. The TaskList extension emits
// the checkbox but no class on either.
type taskLists struct{}

func (taskLists) Transform(doc *gast.Document, _ text.Reader, _ parser.Context) {
	gast.Walk(doc, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
		list, ok := n.(*gast.List)
		if !ok || !entering {
			return gast.WalkContinue, nil
		}
		all := list.ChildCount() > 0
		for li := list.FirstChild(); li != nil; li = li.NextSibling() {
			box := taskBox(li)
			if box == nil {
				all = false
				continue
			}
			state := "unchecked"
			if box.IsChecked {
				state = "checked"
			}
			li.SetAttributeString("class", []byte(state))
		}
		if all {
			list.SetAttributeString("class", []byte("md-tasks"))
		}
		return gast.WalkContinue, nil
	})
}

// taskBox is the checkbox opening a list item, or nil.
func taskBox(li gast.Node) *east.TaskCheckBox {
	if fc := li.FirstChild(); fc != nil {
		if b, ok := fc.FirstChild().(*east.TaskCheckBox); ok {
			return b
		}
	}
	return nil
}

const shortCellMax = 24

var numCell = regexp.MustCompile(`^[+-]?[0-9.,]*[0-9][0-9.,]*%?$`)

// tableHints classes every cell of a column md-short when all its cells
// (header included) are at most shortCellMax characters, and md-num when
// every body cell is a number. Done here rather than in script so the
// first paint is already right.
type tableHints struct{}

func (tableHints) Transform(doc *gast.Document, reader text.Reader, _ parser.Context) {
	src := reader.Source()
	gast.Walk(doc, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
		if t, ok := n.(*east.Table); ok && entering {
			hintTable(t, src)
			return gast.WalkSkipChildren, nil
		}
		return gast.WalkContinue, nil
	})
}

func hintTable(t *east.Table, src []byte) {
	var rows [][]gast.Node
	header := 0
	for r := t.FirstChild(); r != nil; r = r.NextSibling() {
		var cells []gast.Node
		for c := r.FirstChild(); c != nil; c = c.NextSibling() {
			cells = append(cells, c)
		}
		if _, ok := r.(*east.TableHeader); ok {
			header++
		}
		rows = append(rows, cells)
	}
	cols := 0
	for _, r := range rows {
		cols = max(cols, len(r))
	}
	for col := 0; col < cols; col++ {
		short, num, body := true, true, 0
		for i, r := range rows {
			if col >= len(r) {
				continue
			}
			s := strings.TrimSpace(cellText(r[col], src))
			if utf8.RuneCountInString(s) > shortCellMax {
				short = false
			}
			if i >= header {
				body++
				if !numCell.MatchString(s) {
					num = false
				}
			}
		}
		var cls []string
		if short {
			cls = append(cls, "md-short")
		}
		if num && body > 0 {
			cls = append(cls, "md-num")
		}
		if len(cls) == 0 {
			continue
		}
		v := []byte(strings.Join(cls, " "))
		for _, r := range rows {
			if col < len(r) {
				r[col].SetAttributeString("class", v)
			}
		}
	}
}

// cellText is the visible text of a cell: text runs and code spans.
func cellText(n gast.Node, src []byte) string {
	var b strings.Builder
	gast.Walk(n, func(c gast.Node, entering bool) (gast.WalkStatus, error) {
		if !entering {
			return gast.WalkContinue, nil
		}
		switch v := c.(type) {
		case *gast.Text:
			b.Write(v.Segment.Value(src))
		case *gast.String:
			b.Write(v.Value)
		}
		return gast.WalkContinue, nil
	})
	return b.String()
}

// alertKinds are the GitHub alert markers and the title each renders.
var alertKinds = map[string]string{
	"note": "Note", "tip": "Tip", "important": "Important",
	"warning": "Warning", "caution": "Caution",
}

// alerts turns a blockquote whose first line is exactly [!KIND] into a
// callout: the marker line is dropped and the node is tagged for
// alertRenderer. Any other blockquote is left as it is.
type alerts struct{}

const alertAttr = "md-alert"

func (alerts) Transform(doc *gast.Document, reader text.Reader, _ parser.Context) {
	src := reader.Source()
	gast.Walk(doc, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
		if bq, ok := n.(*gast.Blockquote); ok && entering {
			if kind := stripAlertMarker(bq, src); kind != "" {
				bq.SetAttributeString(alertAttr, []byte(kind))
			}
		}
		return gast.WalkContinue, nil
	})
}

// stripAlertMarker removes the [!KIND] line from the quote's first
// paragraph and returns the kind, or "" when the quote is not an alert.
// Inline parsing has already run, so the marker is a few inline nodes
// up to the first line break.
func stripAlertMarker(bq *gast.Blockquote, src []byte) string {
	p, ok := bq.FirstChild().(*gast.Paragraph)
	if !ok {
		return ""
	}
	var first []gast.Node
	var line strings.Builder
	broke := false
	for c := p.FirstChild(); c != nil && !broke; c = c.NextSibling() {
		first = append(first, c)
		switch v := c.(type) {
		case *gast.Text:
			line.Write(v.Segment.Value(src))
			broke = v.SoftLineBreak() || v.HardLineBreak()
		case *gast.String:
			line.Write(v.Value)
		default:
			return ""
		}
	}
	m := strings.ToLower(strings.TrimSpace(line.String()))
	if !strings.HasPrefix(m, "[!") || !strings.HasSuffix(m, "]") {
		return ""
	}
	kind := m[2 : len(m)-1]
	if _, ok := alertKinds[kind]; !ok {
		return ""
	}
	for _, c := range first {
		p.RemoveChild(p, c)
	}
	if p.FirstChild() == nil {
		bq.RemoveChild(bq, p)
	}
	return kind
}

// alertRenderer draws the blockquotes alerts tagged as a callout and
// every other one exactly as goldmark does.
type alertRenderer struct{}

func (alertRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
	reg.Register(gast.KindBlockquote, renderBlockquote)
}

func renderBlockquote(w util.BufWriter, _ []byte, n gast.Node, entering bool) (gast.WalkStatus, error) {
	if v, ok := n.AttributeString(alertAttr); ok {
		kind := string(v.([]byte))
		if entering {
			_, _ = w.WriteString(`<div class="md-alert md-alert-` + kind + `"><p class="md-alert-title">` +
				alertKinds[kind] + "</p>\n")
		} else {
			_, _ = w.WriteString("</div>\n")
		}
		return gast.WalkContinue, nil
	}
	if entering {
		_, _ = w.WriteString("<blockquote")
		html.RenderAttributes(w, n, html.BlockquoteAttributeFilter)
		_, _ = w.WriteString(">\n")
	} else {
		_, _ = w.WriteString("</blockquote>\n")
	}
	return gast.WalkContinue, nil
}