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
}