test: add docslinks linter (#5601)

this checks links in the documentation.
This commit is contained in:
Alessandro Ros
2026-03-21 13:04:14 +01:00
committed by GitHub
parent a82e0c7a9d
commit 0f42f087ab
5 changed files with 308 additions and 7 deletions
+12
View File
@@ -61,6 +61,18 @@ jobs:
- run: make lint-go2api
docslinks:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: "1.25"
- run: make lint-docslinks
docs:
runs-on: ubuntu-22.04
+1 -1
View File
@@ -14,7 +14,7 @@ WHIP is a WebRTC extension that allows to publish streams by using a URL, withou
http://localhost:8889/mystream/whip
```
Be aware that not all browsers can read any codec, check [Supported browsers](../4-other/22-webrtc-specific-features.md#supported-browsers).
Be aware that not all browsers can read any codec, check [Supported browsers](../4-other/22-webrtc-specific-features.md#codec-support-in-browsers).
Depending on the network it might be difficult to establish a connection between server and clients, read [Solving WebRTC connectivity issues](../4-other/22-webrtc-specific-features.md#solving-webrtc-connectivity-issues).
+1 -1
View File
@@ -12,7 +12,7 @@ WHEP is a WebRTC extension that allows to read streams by using a URL, without p
http://localhost:8889/mystream/whep
```
Be aware that not all browsers can read any codec, check [Supported browsers](../4-other/22-webrtc-specific-features.md#supported-browsers).
Be aware that not all browsers can read any codec, check [Supported browsers](../4-other/22-webrtc-specific-features.md#codec-support-in-browsers).
Depending on the network it may be difficult to establish a connection between server and clients, read [Solving WebRTC connectivity issues](../4-other/22-webrtc-specific-features.md#solving-webrtc-connectivity-issues).
@@ -0,0 +1,288 @@
//go:build enable_linters
package docslinks
import (
"bufio"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"testing"
"unicode"
"unicode/utf8"
"github.com/stretchr/testify/require"
)
const (
repoPath = "../../.."
docsPath = "docs/**/*.md"
additionalDoc = "README.md"
)
type docFile struct {
anchors map[string]struct{}
}
type markdownLink struct {
line int
target string
}
func collectDocFile(docPath string) (docFile, error) {
anchors := make(map[string]struct{})
anchorCounts := make(map[string]int)
_, err := scanMarkdown(docPath, func(lineNum int, line string) {
heading, ok := parseHeading(line)
if !ok {
return
}
anchor := slugifyHeading(heading)
if anchor == "" {
return
}
count := anchorCounts[anchor]
anchorCounts[anchor] = count + 1
if count > 0 {
anchor = anchor + "-" + strconv.Itoa(count)
}
anchors[anchor] = struct{}{}
})
if err != nil {
return docFile{}, err
}
return docFile{anchors: anchors}, nil
}
func collectLinks(docPath string) ([]markdownLink, error) {
var links []markdownLink
_, err := scanMarkdown(docPath, func(lineNum int, line string) {
for _, target := range extractInlineLinks(line) {
links = append(links, markdownLink{line: lineNum, target: target})
}
})
return links, err
}
func scanMarkdown(docPath string, cb func(lineNum int, line string)) (map[string]struct{}, error) {
file, err := os.Open(docPath)
if err != nil {
return nil, err
}
defer file.Close()
activeFence := ""
lineNum := 0
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lineNum++
line := scanner.Text()
if fence, ok := fenceDelimiter(line); ok {
if activeFence == "" {
activeFence = fence
} else if activeFence == fence {
activeFence = ""
}
continue
}
if activeFence != "" {
continue
}
cb(lineNum, line)
}
return nil, scanner.Err()
}
func fenceDelimiter(line string) (string, bool) {
trimmed := strings.TrimLeftFunc(line, unicode.IsSpace)
if trimmed == "" {
return "", false
}
r, _ := utf8.DecodeRuneInString(trimmed)
if r != '`' && r != '~' {
return "", false
}
count := 0
for _, candidate := range trimmed {
if candidate != r {
break
}
count++
}
if count < 3 {
return "", false
}
return strings.Repeat(string(r), count), true
}
func parseHeading(line string) (string, bool) {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "#") {
return "", false
}
level := 0
for level < len(trimmed) && trimmed[level] == '#' {
level++
}
if level == 0 || level == len(trimmed) || trimmed[level] != ' ' {
return "", false
}
heading := strings.TrimSpace(trimmed[level:])
heading = strings.TrimRight(heading, " #")
return heading, heading != ""
}
func slugifyHeading(heading string) string {
var b strings.Builder
prevHyphen := false
for _, r := range strings.ToLower(heading) {
switch {
case unicode.IsLetter(r) || unicode.IsNumber(r):
b.WriteRune(r)
prevHyphen = false
case unicode.IsSpace(r) || r == '-':
if b.Len() > 0 && !prevHyphen {
b.WriteByte('-')
prevHyphen = true
}
case r == '/':
if b.Len() > 0 && !prevHyphen {
b.WriteByte('-')
prevHyphen = true
}
case unicode.IsPunct(r) || unicode.IsSymbol(r):
}
}
return strings.Trim(b.String(), "-")
}
func extractInlineLinks(line string) []string {
var out []string
for i := 0; i < len(line); i++ {
if line[i] != '[' || isImageLink(line, i) {
continue
}
closeLabel := strings.IndexByte(line[i:], ']')
if closeLabel < 0 {
continue
}
closeLabel += i
if closeLabel+1 >= len(line) || line[closeLabel+1] != '(' {
continue
}
closeTarget := strings.IndexByte(line[closeLabel+2:], ')')
if closeTarget < 0 {
continue
}
closeTarget += closeLabel + 2
target := strings.TrimSpace(line[closeLabel+2 : closeTarget])
if target != "" {
out = append(out, target)
}
i = closeTarget
}
return out
}
func isImageLink(line string, i int) bool {
return i > 0 && line[i-1] == '!'
}
func splitLinkTarget(target string) (string, string) {
file, anchor, _ := strings.Cut(target, "#")
return file, anchor
}
func isInternalDocLink(targetFile string, targetAnchor string) bool {
if targetFile == "" {
return targetAnchor != ""
}
lower := strings.ToLower(targetFile)
if strings.HasPrefix(lower, "http://") ||
strings.HasPrefix(lower, "https://") ||
strings.HasPrefix(lower, "mailto:") {
return false
}
return strings.HasSuffix(lower, ".md")
}
func toRepoPath(p string) string {
rel, err := filepath.Rel(repoPath, p)
if err != nil {
panic(err)
}
return path.Clean(filepath.ToSlash(rel))
}
func TestDocsLinks(t *testing.T) {
docPaths, err := filepath.Glob(repoPath + "/" + docsPath)
require.NoError(t, err)
docPaths = append(docPaths, repoPath+"/"+additionalDoc)
docs := make(map[string]docFile)
for _, docPath := range docPaths {
doc, err := collectDocFile(docPath)
require.NoError(t, err)
docs[toRepoPath(docPath)] = doc
}
for _, docPath := range docPaths {
links, err := collectLinks(docPath)
require.NoError(t, err)
sourceDocPath := toRepoPath(docPath)
for _, link := range links {
targetFile, targetAnchor := splitLinkTarget(link.target)
if !isInternalDocLink(targetFile, targetAnchor) {
continue
}
resolvedFile := sourceDocPath
if targetFile != "" {
resolvedFile = path.Clean(path.Join(path.Dir(sourceDocPath), targetFile))
}
targetDoc, ok := docs[resolvedFile]
if !ok {
t.Errorf("%s:%d: link target %q does not exist", sourceDocPath, link.line, link.target)
continue
}
if targetAnchor != "" {
if _, ok := targetDoc.anchors[targetAnchor]; !ok {
t.Errorf("%s:%d: anchor %q in link target %q does not exist", sourceDocPath, link.line, targetAnchor, link.target)
}
}
}
}
}
+6 -5
View File
@@ -16,8 +16,7 @@ lint-go:
golangci-lint run -v
lint-go-mod:
go mod tidy
git diff --exit-code
go mod tidy -diff
lint-conf:
go test -v -tags enable_linters ./internal/linters/conf
@@ -25,15 +24,17 @@ lint-conf:
lint-go2api:
go test -v -tags enable_linters ./internal/linters/go2api
lint-docslinks:
go test -v -tags enable_linters ./internal/linters/docslinks
lint-docs:
echo "$$DOCKERFILE_DOCS_LINT" | docker build . -f - -t temp
docker run --rm -v "$(shell pwd)/docs:/s" -w /s temp \
sh -c "prettier --write ."
git diff --exit-code
sh -c "prettier --check ."
lint-api-docs:
echo "$$DOCKERFILE_API_DOCS_LINT" | docker build . -f - -t temp
docker run --rm -v "$(shell pwd)/api:/s" -w /s temp \
sh -c "openapi lint openapi.yaml"
lint: lint-go lint-go-mod lint-conf lint-go2api lint-docs lint-api-docs
lint: lint-go lint-go-mod lint-conf lint-go2api lint-docslinks lint-docs lint-api-docs