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} }