Add internal Markdown parsing
This commit is contained in:
171
internal/validation/markdown.go
Normal file
171
internal/validation/markdown.go
Normal file
@@ -0,0 +1,171 @@
|
||||
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}
|
||||
}
|
||||
Reference in New Issue
Block a user