Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions internal/integration/link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ func TestLinkValidation(t *testing.T) {
ShouldPass: false,
ExpectedRule: "link",
},
{
Name: "blocked domain in frontmatter URL",
FilePath: testdataDir + "links/invalid_frontmatter_blocked_domain.md",
SchemaPath: testdataDir + "links/.mdschema.yml",
ShouldPass: false,
ExpectedRule: "link",
},
}

runTestCases(t, testCases)
Expand Down
24 changes: 24 additions & 0 deletions internal/parser/extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,30 @@ func extractCodeBlock(node *ast.FencedCodeBlock, content []byte) *CodeBlock {
}
}

// extractFrontmatterLinks extracts link-like values from frontmatter data.
func extractFrontmatterLinks(data map[string]any) []*Link {
if data == nil {
return nil
}

var links []*Link
for _, value := range data {
str, ok := value.(string)
if !ok {
continue
}
if strings.HasPrefix(str, "#") || strings.HasPrefix(str, "http://") || strings.HasPrefix(str, "https://") {
links = append(links, &Link{
URL: str,
IsInternal: isInternalLink(str),
Line: 1,
Column: 1,
})
}
}
return links
}

func extractLink(node *ast.Link, content []byte) *Link {
// Use ast.Walk to recursively extract all text (handles emphasis, code, etc.)
var textBuf bytes.Buffer
Expand Down
1 change: 1 addition & 0 deletions internal/parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func (p *Parser) Parse(path string, content []byte) (*Document, error) {
frontMatter = &FrontMatter{
Format: "yaml",
Data: metaData,
Links: extractFrontmatterLinks(metaData),
}
}

Expand Down
1 change: 1 addition & 0 deletions internal/parser/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ type FrontMatter struct {
Format string // "yaml" or "toml"
Content string
Data map[string]any
Links []*Link
}

// LineLocatable is implemented by elements that have a line position
Expand Down
5 changes: 4 additions & 1 deletion internal/rules/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ func (r *LinkValidationRule) ValidateWithContext(ctx *vast.Context) []Violation

linkRule := ctx.Schema.Links

// Collect all links from the document (not just schema-matched sections)
// Collect all links from the document
links := r.collectAllLinks(ctx.Tree.Document.Root)
if fm := ctx.Tree.Document.FrontMatter; fm != nil {
links = append(links, fm.Links...)
}

// Get document directory for relative path resolution
docDir := filepath.Dir(ctx.Tree.Document.Path)
Expand Down
154 changes: 154 additions & 0 deletions internal/rules/link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,160 @@ func TestLinkValidationRootRelativePath(t *testing.T) {
}
}

func TestLinkValidationFrontmatterBlockedDomain(t *testing.T) {
p := parser.New()
doc, err := p.Parse("test.md", []byte("---\nrepo: https://blocked.com/repo\n---\n\n# Title\n"))
if err != nil {
t.Fatalf("Parse() error: %v", err)
}

s := &schema.Schema{
Links: &schema.LinkRule{
BlockedDomains: []string{"blocked.com"},
},
}

ctx := vast.NewContext(doc, s, "")
rule := NewLinkValidationRule()
violations := rule.ValidateWithContext(ctx)

if len(violations) == 0 {
t.Fatal("Expected violation for frontmatter URL to blocked domain")
}

found := false
for _, v := range violations {
if strings.Contains(v.Message, "blocked domain") {
found = true
break
}
}
if !found {
t.Errorf("Expected violation mentioning blocked domain, got: %v", violations)
}
}

func TestLinkValidationFrontmatterAllowedDomains(t *testing.T) {
p := parser.New()
doc, err := p.Parse("test.md", []byte("---\nrepo: https://notallowed.com/repo\n---\n\n# Title\n"))
if err != nil {
t.Fatalf("Parse() error: %v", err)
}

s := &schema.Schema{
Links: &schema.LinkRule{
AllowedDomains: []string{"github.com"},
},
}

ctx := vast.NewContext(doc, s, "")
rule := NewLinkValidationRule()
violations := rule.ValidateWithContext(ctx)

if len(violations) == 0 {
t.Fatal("Expected violation for frontmatter URL not in allowed domains")
}
}

func TestLinkValidationFrontmatterAllowedDomainPass(t *testing.T) {
p := parser.New()
doc, err := p.Parse("test.md", []byte("---\nrepo: https://github.com/user/repo\n---\n\n# Title\n"))
if err != nil {
t.Fatalf("Parse() error: %v", err)
}

s := &schema.Schema{
Links: &schema.LinkRule{
AllowedDomains: []string{"github.com"},
},
}

ctx := vast.NewContext(doc, s, "")
rule := NewLinkValidationRule()
violations := rule.ValidateWithContext(ctx)

if len(violations) != 0 {
t.Errorf("Expected no violations for allowed domain, got %d: %v", len(violations), violations)
}
}

func TestLinkValidationFrontmatterNonStringSkipped(t *testing.T) {
p := parser.New()
doc, err := p.Parse("test.md", []byte("---\ncount: 123\n---\n\n# Title\n"))
if err != nil {
t.Fatalf("Parse() error: %v", err)
}

s := &schema.Schema{
Links: &schema.LinkRule{
BlockedDomains: []string{"blocked.com"},
},
}

ctx := vast.NewContext(doc, s, "")
rule := NewLinkValidationRule()
violations := rule.ValidateWithContext(ctx)

if len(violations) != 0 {
t.Errorf("Expected no violations for non-URL frontmatter value, got %d", len(violations))
}
}

func TestLinkValidationFrontmatterInternalAnchor(t *testing.T) {
p := parser.New()
doc, err := p.Parse("test.md", []byte("---\nref: \"#nonexistent\"\n---\n\n# Title\n"))
if err != nil {
t.Fatalf("Parse() error: %v", err)
}

s := &schema.Schema{
Links: &schema.LinkRule{
ValidateInternal: true,
},
}

ctx := vast.NewContext(doc, s, "")
rule := NewLinkValidationRule()
violations := rule.ValidateWithContext(ctx)

if len(violations) == 0 {
t.Fatal("Expected violation for broken anchor in frontmatter")
}

found := false
for _, v := range violations {
if strings.Contains(v.Message, "nonexistent") && strings.Contains(v.Message, "does not exist") {
found = true
break
}
}
if !found {
t.Errorf("Expected violation mentioning broken anchor, got: %v", violations)
}
}

func TestLinkValidationFrontmatterValidAnchor(t *testing.T) {
p := parser.New()
doc, err := p.Parse("test.md", []byte("---\nref: \"#details\"\n---\n\n# Title\n\n## Details\n\nSome content.\n"))
if err != nil {
t.Fatalf("Parse() error: %v", err)
}

s := &schema.Schema{
Links: &schema.LinkRule{
ValidateInternal: true,
},
}

ctx := vast.NewContext(doc, s, "")
rule := NewLinkValidationRule()
violations := rule.ValidateWithContext(ctx)

if len(violations) != 0 {
t.Errorf("Expected no violations for valid anchor in frontmatter, got %d: %v", len(violations), violations)
}
}

func TestLinkValidationRootRelativePathBroken(t *testing.T) {
tmpDir := t.TempDir()

Expand Down
9 changes: 9 additions & 0 deletions testdata/links/invalid_frontmatter_blocked_domain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
repo: https://blocked-domain.com/user/repo
---

# Test Document

## Introduction

This tests frontmatter URL validation against blocked domains.