Files
ReleaseKit/internal/validation/markdown.go

190 lines
4.9 KiB
Go

package validation
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"unicode"
"github.com/yuin/goldmark/v2/ast"
"github.com/yuin/goldmark/v2/parser"
)
type markdownLocation struct {
Line int
Column int
}
type markdownLink struct {
Destination string
Title string
Image bool
Reference bool
Location markdownLocation
}
type markdownDocument struct {
links []markdownLink
anchors map[string]struct{}
}
type markdownCache struct {
tree *CandidateTree
byPath map[string]*markdownDocument
byObjectID map[string]*markdownDocument
}
func newMarkdownCache(tree *CandidateTree) *markdownCache {
return &markdownCache{
tree: tree,
byPath: make(map[string]*markdownDocument),
byObjectID: make(map[string]*markdownDocument),
}
}
func (cache *markdownCache) document(ctx context.Context, path string) (*markdownDocument, error) {
if document, ok := cache.byPath[path]; ok {
return document, nil
}
entry, ok := cache.tree.Entry(path)
if !ok || !isRegularFile(entry) {
return nil, fmt.Errorf("committed path %q is not a regular file", path)
}
if document, ok := cache.byObjectID[entry.ObjectID]; ok {
cache.byPath[path] = document
return document, nil
}
source, err := cache.tree.ReadFile(ctx, path)
if err != nil {
return nil, err
}
document := parseMarkdown(source)
source = nil
cache.byObjectID[entry.ObjectID] = document
cache.byPath[path] = document
return document, nil
}
func parseMarkdown(source []byte) *markdownDocument {
root := parser.New().Parse(source)
document := &markdownDocument{
anchors: make(map[string]struct{}),
}
lineStarts := markdownLineStarts(source)
usedAnchors := make(map[string]struct{})
nextSuffix := make(map[string]int)
_ = ast.Walk(root, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch node := node.(type) {
case *ast.Link:
document.links = append(document.links, markdownLink{
Destination: strings.Clone(string(node.Destination.Value(source))),
Title: strings.Clone(string(node.Title.Value(source))),
Reference: node.Reference != nil,
Location: markdownLocationAt(lineStarts, node.Pos()),
})
case *ast.Image:
document.links = append(document.links, markdownLink{
Destination: strings.Clone(string(node.Destination.Value(source))),
Title: strings.Clone(string(node.Title.Value(source))),
Image: true,
Reference: node.Reference != nil,
Location: markdownLocationAt(lineStarts, node.Pos()),
})
// Image labels contribute alternative text, not navigable destinations.
return ast.WalkSkipChildren, nil
case *ast.AutoLink:
document.links = append(document.links, markdownLink{
Destination: strings.Clone(string(node.Destination.Value(source))),
Location: markdownLocationAt(lineStarts, node.Pos()),
})
case *ast.Heading:
base := markdownAnchor(markdownHeadingText(node, source))
anchor := base
if _, exists := usedAnchors[anchor]; exists {
suffix, ok := nextSuffix[base]
if !ok {
suffix = 1
}
for {
anchor = base + "-" + strconv.Itoa(suffix)
suffix++
if _, exists := usedAnchors[anchor]; !exists {
break
}
}
nextSuffix[base] = suffix
}
usedAnchors[anchor] = struct{}{}
document.anchors[anchor] = struct{}{}
}
return ast.WalkContinue, nil
})
return document
}
func markdownHeadingText(heading *ast.Heading, source []byte) string {
var result strings.Builder
var appendNode func(ast.Node)
appendNode = func(node ast.Node) {
switch node := node.(type) {
case *ast.RawHTML:
return
case *ast.Text:
result.WriteString(node.Value.Value(source))
if node.SoftLineBreak() || node.HardLineBreak() {
result.WriteByte(' ')
}
return
case *ast.CodeSpan:
result.WriteString(node.Value.Value(source))
return
case *ast.AutoLink:
result.WriteString(node.Label.Value(source))
return
}
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
appendNode(child)
}
}
for child := heading.FirstChild(); child != nil; child = child.NextSibling() {
appendNode(child)
}
return result.String()
}
func markdownAnchor(heading string) string {
heading = strings.ToLower(heading)
var result strings.Builder
for _, r := range heading {
if unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_' || r == '-' || r == ' ' {
result.WriteRune(r)
}
}
return strings.ReplaceAll(result.String(), " ", "-")
}
func markdownLineStarts(source []byte) []int {
starts := []int{0}
for offset, value := range source {
if value == '\n' {
starts = append(starts, offset+1)
}
}
return starts
}
func markdownLocationAt(lineStarts []int, offset int) markdownLocation {
if offset < 0 {
return markdownLocation{}
}
lineIndex := sort.Search(len(lineStarts), func(index int) bool {
return lineStarts[index] > offset
}) - 1
return markdownLocation{Line: lineIndex + 1, Column: offset - lineStarts[lineIndex] + 1}
}