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
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,9 +372,37 @@ frontmatter:
- { name: "repo", optional: true, format: url }
```

**Field types:** `string`, `number`, `boolean`, `array`, `date`
**Field types:** `string`, `number`, `boolean`, `array`, `date`, `object`
**Field formats:** `date` (YYYY-MM-DD), `email`, `url`

##### Nested Frontmatter Keys

Use dot-notation in `name` to validate nested keys:

```yaml
frontmatter:
fields:
- { name: "title" }
- { name: "metadata", type: object }
- { name: "metadata.author" }
- { name: "metadata.version" }
- { name: "metadata.homepage", optional: true, format: url }
```

Validates a document like:

```yaml
---
title: My Document
metadata:
author: example-org
version: "1.0"
---
```

If a key segment contains a literal dot, escape it with a backslash:
`name: "weird\\.key"`.

## Use Cases

- **Documentation Standards** - Enforce consistent README structure across repositories
Expand Down
141 changes: 133 additions & 8 deletions internal/rules/frontmatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func (r *FrontmatterRule) ValidateWithContext(ctx *vast.Context) []Violation {

// Validate required fields
for _, field := range config.Fields {
value, exists := fm.Data[field.Name]
value, exists := lookupField(fm.Data, field.Name)

if !field.Optional && !exists {
violations = append(violations,
Expand Down Expand Up @@ -91,6 +91,56 @@ func (r *FrontmatterRule) ValidateWithContext(ctx *vast.Context) []Violation {
return violations
}

// splitFieldPath splits a dot-notation path into segments. A literal dot can
// be escaped with a backslash (e.g. "weird\\.key" → ["weird.key"]).
func splitFieldPath(name string) []string {
segments := []string{}
var current strings.Builder
for i := 0; i < len(name); i++ {
c := name[i]
if c == '\\' && i+1 < len(name) && name[i+1] == '.' {
current.WriteByte('.')
i++
continue
}
if c == '.' {
segments = append(segments, current.String())
current.Reset()
continue
}
current.WriteByte(c)
}
segments = append(segments, current.String())
return segments
}

// lookupField walks a frontmatter data map using a dot-notation path. It
// handles both map[string]any and map[any]any (yaml.v3 may produce the
// latter for nested maps).
func lookupField(data map[string]any, name string) (any, bool) {
segments := splitFieldPath(name)
var current any = data
for _, seg := range segments {
switch m := current.(type) {
case map[string]any:
v, ok := m[seg]
if !ok {
return nil, false
}
current = v
case map[any]any:
v, ok := m[seg]
if !ok {
return nil, false
}
current = v
default:
return nil, false
}
}
return current, true
}

// validateFieldType checks if a field value matches the expected type
func (r *FrontmatterRule) validateFieldType(name string, value any, expectedType schema.FieldType) string {
switch expectedType {
Expand All @@ -113,6 +163,12 @@ func (r *FrontmatterRule) validateFieldType(name string, value any, expectedType
if _, ok := value.([]any); !ok {
return fmt.Sprintf("Frontmatter field '%s' should be an array", name)
}
case schema.FieldTypeObject:
switch value.(type) {
case map[string]any, map[any]any:
default:
return fmt.Sprintf("Frontmatter field '%s' should be an object", name)
}
case schema.FieldTypeDate:
// Date can be string or time.Time depending on YAML parsing
// YAML may parse dates like 2024-01-15 as time.Time
Expand Down Expand Up @@ -194,19 +250,86 @@ func (r *FrontmatterRule) Generate(builder *strings.Builder, s *schema.Schema) b
return false
}

tree := buildFrontmatterTree(s.Frontmatter.Fields)

builder.WriteString("---\n")
r.writeFrontmatterTree(builder, tree, 0)
builder.WriteString("---\n\n")
return true
}

for _, field := range s.Frontmatter.Fields {
placeholder := r.getPlaceholder(field)
if !field.Optional {
builder.WriteString(field.Name + ": " + placeholder + " # required\n")
// fmTreeNode is a node in the tree built from dot-notation field paths.
// Branches (nodes with children) emit as YAML maps; leaves emit a placeholder.
type fmTreeNode struct {
key string
field *schema.FrontmatterField
children []*fmTreeNode
}

func buildFrontmatterTree(fields []schema.FrontmatterField) []*fmTreeNode {
var roots []*fmTreeNode
for i := range fields {
f := fields[i]
segments := splitFieldPath(f.Name)
insertFrontmatterPath(&roots, segments, &f, 0)
}
return roots
}

func insertFrontmatterPath(siblings *[]*fmTreeNode, segments []string, f *schema.FrontmatterField, depth int) {
seg := segments[depth]
var node *fmTreeNode
for _, s := range *siblings {
if s.key == seg {
node = s
break
}
}
if node == nil {
node = &fmTreeNode{key: seg}
*siblings = append(*siblings, node)
}
if depth == len(segments)-1 {
node.field = f
return
}
insertFrontmatterPath(&node.children, segments, f, depth+1)
}

func (r *FrontmatterRule) writeFrontmatterTree(builder *strings.Builder, nodes []*fmTreeNode, depth int) {
indent := strings.Repeat(" ", depth)
for _, n := range nodes {
if len(n.children) > 0 {
if hasRequiredDescendant(n) {
builder.WriteString(indent + n.key + ": # required\n")
} else {
builder.WriteString(indent + n.key + ":\n")
}
r.writeFrontmatterTree(builder, n.children, depth+1)
continue
}
if n.field == nil {
continue
}
placeholder := r.getPlaceholder(*n.field)
if !n.field.Optional {
builder.WriteString(indent + n.key + ": " + placeholder + " # required\n")
} else {
builder.WriteString(field.Name + ": " + placeholder + "\n")
builder.WriteString(indent + n.key + ": " + placeholder + "\n")
}
}
}

builder.WriteString("---\n\n")
return true
func hasRequiredDescendant(n *fmTreeNode) bool {
if n.field != nil && !n.field.Optional {
return true
}
for _, c := range n.children {
if hasRequiredDescendant(c) {
return true
}
}
return false
}

// getPlaceholder returns an appropriate placeholder value based on field type/format
Expand All @@ -233,6 +356,8 @@ func (r *FrontmatterRule) getPlaceholder(field schema.FrontmatterField) string {
return "[\"item1\", \"item2\"]"
case schema.FieldTypeDate:
return "2024-01-01"
case schema.FieldTypeObject:
return "{}"
default:
return "\"TODO\""
}
Expand Down
Loading