152 lines
3.9 KiB
Go
152 lines
3.9 KiB
Go
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)
|
|
}
|