package docsite import ( "fmt" "strings" gast "github.com/yuin/goldmark/ast" ) // checkLinks verifies every link and image destination in the pages // (guides and ingested READMEs): an anchor must be a heading ID of its // page, a relative .md link must resolve to a page of the site and its // fragment to a heading of that page. Heading IDs come from the same parse // and slug algorithm the renderer uses, so the site and the checker agree. // External http(s) and mailto links are allowed and never fetched. func (s *site) checkLinks(docs []parsedDoc) []Problem { ids := map[string]map[string]bool{} for _, d := range docs { if d.page == nil { continue } set := map[string]bool{} for _, h := range pageHeadings(d.doc, d.body) { set[h.ID] = true } ids[d.page.Source] = set } var problems []Problem for _, d := range docs { if d.page == nil { continue } _ = gast.Walk(d.doc, func(n gast.Node, entering bool) (gast.WalkStatus, error) { if !entering { return gast.WalkContinue, nil } var dest string switch l := n.(type) { case *gast.Link: dest = string(l.Destination) case *gast.Image: dest = string(l.Destination) default: return gast.WalkContinue, nil } if msg := s.checkLink(d.page, dest, ids); msg != "" { problems = append(problems, Problem{File: d.file, Line: lineOf(d.body, nodeOffset(n), d.line), Rule: "link", Message: msg}) } return gast.WalkContinue, nil }) } return problems } // checkLink returns the problem message for one destination, or "". func (s *site) checkLink(from *Page, dest string, ids map[string]map[string]bool) string { switch { case dest == "": return "empty link destination does not resolve" case strings.HasPrefix(dest, "http://"), strings.HasPrefix(dest, "https://"), strings.HasPrefix(dest, "mailto:"): return "" case strings.HasPrefix(dest, "#"): frag := dest[1:] if !ids[from.Source][frag] { return fmt.Sprintf("#%s not found in %s", frag, from.Source) } return "" case hasScheme(dest), strings.HasPrefix(dest, "/"): return fmt.Sprintf("%s does not resolve", dest) } target, frag, _ := strings.Cut(dest, "#") if strings.HasSuffix(target, ".md") { p, _, ok := s.resolveLink(from, dest) if !ok { return fmt.Sprintf("%s does not resolve", dest) } if frag != "" && !ids[p.Source][frag] { return fmt.Sprintf("#%s not found in %s", frag, p.Source) } return "" } // Any other relative path (a source file, .planning/, examples/) works // on the git host but breaks on the site. Module READMEs are read on // the git host first, so only guide pages are held to this. if from.Module == "" { return fmt.Sprintf("%s does not resolve", dest) } return "" } // nodeOffset returns the source offset of an inline node: its first text // descendant, else the first line of its enclosing block. func nodeOffset(n gast.Node) int { off := -1 _ = gast.Walk(n, func(c gast.Node, entering bool) (gast.WalkStatus, error) { if t, ok := c.(*gast.Text); entering && ok { off = t.Segment.Start return gast.WalkStop, nil } return gast.WalkContinue, nil }) if off >= 0 { return off } for p := n; p != nil; p = p.Parent() { if p.Type() == gast.TypeBlock && p.Lines().Len() > 0 { return p.Lines().At(0).Start } } return -1 }