diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ce5e6764..80295b07 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 diff --git a/docs/2-publish/04-webrtc-clients.md b/docs/2-publish/04-webrtc-clients.md index c59e1c7c..09b9f21d 100644 --- a/docs/2-publish/04-webrtc-clients.md +++ b/docs/2-publish/04-webrtc-clients.md @@ -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). diff --git a/docs/3-read/03-webrtc.md b/docs/3-read/03-webrtc.md index 1cada6f3..86734b0e 100644 --- a/docs/3-read/03-webrtc.md +++ b/docs/3-read/03-webrtc.md @@ -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). diff --git a/internal/linters/docslinks/docslinks_test.go b/internal/linters/docslinks/docslinks_test.go new file mode 100644 index 00000000..4cc21454 --- /dev/null +++ b/internal/linters/docslinks/docslinks_test.go @@ -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) + } + } + } + } +} diff --git a/scripts/lint.mk b/scripts/lint.mk index 5b4d2cc7..97c5de05 100644 --- a/scripts/lint.mk +++ b/scripts/lint.mk @@ -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