172 lines
4.2 KiB
Go
172 lines
4.2 KiB
Go
package validation
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
documents map[string]*markdownDocument
|
|
}
|
|
|
|
func newMarkdownCache(tree *CandidateTree) *markdownCache {
|
|
return &markdownCache{
|
|
tree: tree,
|
|
documents: make(map[string]*markdownDocument),
|
|
}
|
|
}
|
|
|
|
func (cache *markdownCache) document(ctx context.Context, path string) (*markdownDocument, error) {
|
|
if document, ok := cache.documents[path]; ok {
|
|
return document, nil
|
|
}
|
|
source, err := cache.tree.ReadFile(ctx, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
document := parseMarkdown(source)
|
|
cache.documents[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: node.Destination.Value(source),
|
|
Title: node.Title.Value(source),
|
|
Reference: node.Reference != nil,
|
|
Location: markdownLocationAt(lineStarts, node.Pos()),
|
|
})
|
|
case *ast.Image:
|
|
document.links = append(document.links, markdownLink{
|
|
Destination: node.Destination.Value(source),
|
|
Title: node.Title.Value(source),
|
|
Image: true,
|
|
Reference: node.Reference != nil,
|
|
Location: markdownLocationAt(lineStarts, node.Pos()),
|
|
})
|
|
case *ast.AutoLink:
|
|
document.links = append(document.links, markdownLink{
|
|
Destination: 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
|
|
}
|
|
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}
|
|
}
|