package configkit import ( "bufio" "fmt" "net/url" "os" "path/filepath" "regexp" "strings" "testing" "unicode" ) var markdownLinkPattern = regexp.MustCompile(`(?m)(?:^|[[:space:]])\[[^\]]*\]\(([^)[:space:]]+)`) func TestLocalDocumentationLinks(t *testing.T) { repository, err := filepath.Abs(".") if err != nil { t.Fatal(err) } var failures []string err = filepath.WalkDir(repository, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if entry.IsDir() { if entry.Name() == ".git" { return filepath.SkipDir } return nil } if filepath.Ext(path) != ".md" { return nil } content, readErr := os.ReadFile(path) if readErr != nil { return readErr } for _, match := range markdownLinkPattern.FindAllStringSubmatch(string(content), -1) { target := strings.Trim(match[1], "<>") if strings.Contains(target, "://") || strings.HasPrefix(target, "mailto:") { continue } filePart, fragment, _ := strings.Cut(target, "#") decoded, decodeErr := url.PathUnescape(filePart) if decodeErr != nil { failures = append(failures, fmt.Sprintf("%s: invalid escaped link %q", relativePath(repository, path), target)) continue } linked := path if decoded != "" { linked = filepath.Clean(filepath.Join(filepath.Dir(path), filepath.FromSlash(decoded))) } _, statErr := os.Stat(linked) outside := linked != repository && !strings.HasPrefix(linked, repository+string(filepath.Separator)) if os.IsNotExist(statErr) && outside && optionalSiblingSource(repository, linked) { continue // Recorded sibling-source links are optional in isolated clones. } if statErr != nil { failures = append(failures, fmt.Sprintf("%s: missing local link %q", relativePath(repository, path), target)) continue } if fragment != "" && filepath.Ext(linked) == ".md" && !markdownHasFragment(linked, fragment) { failures = append(failures, fmt.Sprintf("%s: missing fragment in %q", relativePath(repository, path), target)) } } return nil }) if err != nil { t.Fatal(err) } if len(failures) != 0 { t.Fatalf("documentation link failures:\n%s", strings.Join(failures, "\n")) } } func optionalSiblingSource(repository, linked string) bool { relative, err := filepath.Rel(filepath.Dir(repository), linked) if err != nil || relative == "." || filepath.IsAbs(relative) { return false } parts := strings.Split(filepath.ToSlash(relative), "/") if len(parts) == 0 { return false } switch strings.ToLower(parts[0]) { case "narratio", "notarius", "weatherreporter", "seriatim": return true default: return false } } func markdownHasFragment(path, wanted string) bool { file, err := os.Open(path) if err != nil { return false } defer file.Close() wanted, err = url.PathUnescape(wanted) if err != nil { return false } seen := make(map[string]int) scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if !strings.HasPrefix(line, "#") { continue } heading := strings.TrimSpace(strings.TrimLeft(line, "#")) base := markdownFragment(heading) count := seen[base] seen[base]++ fragment := base if count > 0 { fragment = fmt.Sprintf("%s-%d", base, count) } if fragment == wanted { return true } } return false } func markdownFragment(heading string) string { var result strings.Builder previousSpace := false for _, character := range strings.ToLower(heading) { switch { case unicode.IsLetter(character), unicode.IsDigit(character), character == '_', character == '-': result.WriteRune(character) previousSpace = false case unicode.IsSpace(character): if result.Len() > 0 && !previousSpace { result.WriteByte('-') previousSpace = true } } } return strings.TrimRight(result.String(), "-") } func relativePath(root, path string) string { relative, err := filepath.Rel(root, path) if err != nil { return path } return filepath.ToSlash(relative) }