Skip to content
Draft
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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,24 @@ The following fields are available:
| `guidelines.order` | Alphabetical | Page ordering by filename stem. Items listed in `order` take precedence over frontmatter `weight` values. |
| `guidelines.exclude` | (none) | Filename stems to skip. Takes precedence over `order`. |
| `guidelines.enabled` | `true` | Set to `false` to suppress guideline generation even when files exist |
| `resources.enabled` | `true` | Set to `false` to suppress the `/resources/` page |
| `resources.extra_links` | (none) | Additional resource links to display on the resources page (see example below) |

### Custom resource links

Every generated site includes a `/resources/` page linking to the Vale ecosystem (Vale docs, Vale Studio, Vale on GitHub). Package authors can add custom links:

```yaml
resources:
extra_links:
- label: Microsoft Writing Style Guide
url: https://github.com/vale-cli/Microsoft
description: Vale implementation of the Microsoft Writing Style Guide
- label: Our internal style guide
url: https://wiki.example.com/style
```

Set `resources.enabled: false` to suppress the resources page entirely. Footer links (Vale, Vale Studio) appear regardless.

## How it works

Expand Down Expand Up @@ -319,6 +337,14 @@ rulebound returns the following exit codes:
| 3 | Hugo not found or version too old |
| 4 | Hugo build failure |

## Vale resources

rulebound generates documentation for Vale packages. Learn more about Vale:

- [Vale](https://vale.sh) -- A linter for prose, built with speed and extensibility in mind
- [Vale Studio](https://studio.vale.sh) -- Test and debug Vale rules in the browser
- [Vale on GitHub](https://github.com/vale-cli/vale) -- Source code, issues, and releases

## Project structure

The repository is organized as follows:
Expand Down
5 changes: 5 additions & 0 deletions examples/starter-package/rulebound.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ description: Documentation standards for technical writing teams
baseURL: /
pages:
enabled: true
resources:
extra_links:
- label: Google Developer Documentation Style Guide
url: https://developers.google.com/style
description: Google's comprehensive style guide for developer documentation
21 changes: 21 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ type Config struct {

// Pages controls how content pages are processed.
Pages PagesConfig `yaml:"pages"`

// Resources controls the resources page and footer ecosystem links.
Resources ResourcesConfig `yaml:"resources"`
}

// GuidelinesConfig controls how the build processes editorial guidelines.
Expand All @@ -53,6 +56,24 @@ type PagesConfig struct {
Enabled *bool `yaml:"enabled"`
}

// ResourceLink represents a single external resource link.
type ResourceLink struct {
// Label is the display text for the link.
Label string `yaml:"label"`
// URL is the link target.
URL string `yaml:"url"`
// Description is an optional short description shown on the resources page.
Description string `yaml:"description"`
}

// ResourcesConfig controls the resources page and footer links.
type ResourcesConfig struct {
// Enabled controls whether the /resources/ page is generated. Default: true (nil means true).
Enabled *bool `yaml:"enabled"`
// ExtraLinks are additional resource links provided by the package author.
ExtraLinks []ResourceLink `yaml:"extra_links"`
}

// Load reads and parses the rulebound.yml file located in packageDir.
// If the file does not exist, Load returns a Config populated with defaults:
// - Title: base name of packageDir
Expand Down
98 changes: 98 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,93 @@ pages:
}
}

// ── Resources config tests ──────────────────────────────────────────────────

func TestLoadResourcesConfig_WithExtraLinks(t *testing.T) {
dir := t.TempDir()
yml := `title: My Style Guide
baseURL: /
resources:
extra_links:
- label: Microsoft Style Guide
url: https://github.com/vale-cli/Microsoft
description: Vale implementation of the Microsoft Writing Style Guide
- label: Google Style Guide
url: https://github.com/vale-cli/Google
description: Vale implementation of the Google Developer Documentation Style Guide
`
if err := os.WriteFile(filepath.Join(dir, "rulebound.yml"), []byte(yml), 0o644); err != nil {
t.Fatalf("setup: %v", err)
}

cfg, err := config.Load(dir)
if err != nil {
t.Fatalf("Load() returned unexpected error: %v", err)
}

if len(cfg.Resources.ExtraLinks) != 2 {
t.Fatalf("ExtraLinks length = %d, want 2", len(cfg.Resources.ExtraLinks))
}
if cfg.Resources.ExtraLinks[0].Label != "Microsoft Style Guide" {
t.Errorf("ExtraLinks[0].Label = %q, want %q", cfg.Resources.ExtraLinks[0].Label, "Microsoft Style Guide")
}
if cfg.Resources.ExtraLinks[0].URL != "https://github.com/vale-cli/Microsoft" {
t.Errorf("ExtraLinks[0].URL = %q, want %q", cfg.Resources.ExtraLinks[0].URL, "https://github.com/vale-cli/Microsoft")
}
if cfg.Resources.ExtraLinks[0].Description != "Vale implementation of the Microsoft Writing Style Guide" {
t.Errorf("ExtraLinks[0].Description = %q", cfg.Resources.ExtraLinks[0].Description)
}
if cfg.Resources.ExtraLinks[1].Label != "Google Style Guide" {
t.Errorf("ExtraLinks[1].Label = %q, want %q", cfg.Resources.ExtraLinks[1].Label, "Google Style Guide")
}
}

func TestLoadResourcesConfig_DefaultsWhenAbsent(t *testing.T) {
dir := t.TempDir()
yml := `title: No Resources Config
baseURL: /
`
if err := os.WriteFile(filepath.Join(dir, "rulebound.yml"), []byte(yml), 0o644); err != nil {
t.Fatalf("setup: %v", err)
}

cfg, err := config.Load(dir)
if err != nil {
t.Fatalf("Load() returned unexpected error: %v", err)
}

if cfg.Resources.Enabled != nil {
t.Errorf("Resources.Enabled should be nil (unset), got %v", *cfg.Resources.Enabled)
}
if len(cfg.Resources.ExtraLinks) != 0 {
t.Errorf("ExtraLinks should be empty, got %v", cfg.Resources.ExtraLinks)
}
}

func TestLoadResourcesConfig_ExplicitlyDisabled(t *testing.T) {
dir := t.TempDir()
yml := `title: Disabled Resources
baseURL: /
resources:
enabled: false
`
if err := os.WriteFile(filepath.Join(dir, "rulebound.yml"), []byte(yml), 0o644); err != nil {
t.Fatalf("setup: %v", err)
}

cfg, err := config.Load(dir)
if err != nil {
t.Fatalf("Load() returned unexpected error: %v", err)
}

if cfg.Resources.Enabled == nil {
t.Fatal("Resources.Enabled should not be nil")
}
if *cfg.Resources.Enabled {
t.Error("Resources.Enabled should be false")
}
}

func TestLoadExistingFieldsStillWork(t *testing.T) {
dir := t.TempDir()
yml := `title: Full Config
Expand All @@ -372,6 +459,11 @@ pages:
guidelines:
section_title: Editorial
enabled: true
resources:
extra_links:
- label: Custom Link
url: https://example.com/custom
description: A custom resource
`
if err := os.WriteFile(filepath.Join(dir, "rulebound.yml"), []byte(yml), 0o644); err != nil {
t.Fatalf("setup: %v", err)
Expand Down Expand Up @@ -400,4 +492,10 @@ guidelines:
if cfg.Guidelines.SectionTitle != "Editorial" {
t.Errorf("Guidelines.SectionTitle = %q, want %q", cfg.Guidelines.SectionTitle, "Editorial")
}
if len(cfg.Resources.ExtraLinks) != 1 {
t.Fatalf("Resources.ExtraLinks length = %d, want 1", len(cfg.Resources.ExtraLinks))
}
if cfg.Resources.ExtraLinks[0].Label != "Custom Link" {
t.Errorf("Resources.ExtraLinks[0].Label = %q, want %q", cfg.Resources.ExtraLinks[0].Label, "Custom Link")
}
}
15 changes: 13 additions & 2 deletions internal/generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,19 @@ func GenerateSite(result *parser.ParseResult, cfg *config.Config, outputDir stri
return err
}

// data/site.json
if err := generateSiteJSON(rules, guidelinesCount, sectionTitle, dataDir); err != nil {
// ── Resources ────────────────────────────────────────────────────────
resourceLinks := buildResourceLinks(cfg)

// Resources page (unless explicitly disabled).
resourcesEnabled := cfg.Resources.Enabled == nil || *cfg.Resources.Enabled
if resourcesEnabled {
if err := generateResourcesPage(outputDir); err != nil {
return err
}
}

// data/site.json (always includes resource links for footer)
if err := generateSiteJSON(rules, guidelinesCount, sectionTitle, resourceLinks, dataDir); err != nil {
return err
}

Expand Down
131 changes: 131 additions & 0 deletions internal/generator/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,137 @@ func TestGenerateSite_WithEmptyPages_GuidelinesRun(t *testing.T) {
}
}

// ── Resources ──────────────────────────────────────────────────────────────

func TestGenerateSite_SiteJSON_ContainsDefaultResourceLinks(t *testing.T) {
outDir := t.TempDir()
result := &parser.ParseResult{
Rules: []*parser.ValeRule{makeRule("Avoid", "existence", "error")},
}
cfg := &config.Config{Title: "Test Guide", BaseURL: "/"}

if err := generator.GenerateSite(result, cfg, outDir); err != nil {
t.Fatalf("GenerateSite: %v", err)
}

data := readFile(t, filepath.Join(outDir, "data", "site.json"))
var stats map[string]interface{}
if err := json.Unmarshal([]byte(data), &stats); err != nil {
t.Fatalf("site.json is not valid JSON: %v", err)
}

links, ok := stats["resource_links"].([]interface{})
if !ok {
t.Fatal("site.json missing resource_links array")
}
if len(links) != 3 {
t.Fatalf("resource_links length = %d, want 3 defaults", len(links))
}

// Verify first default is Vale
first := links[0].(map[string]interface{})
if first["label"] != "Vale" {
Comment on lines +1114 to +1116
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test uses an unchecked type assertion (links[0].(map[string]interface{})), which will panic with a less-informative stack trace if the JSON structure changes. Prefer a checked assertion with a clear test failure message.

Copilot uses AI. Check for mistakes.
t.Errorf("first default label = %v, want 'Vale'", first["label"])
}
if first["url"] != "https://vale.sh" {
t.Errorf("first default url = %v, want 'https://vale.sh'", first["url"])
}
}

func TestGenerateSite_SiteJSON_ExtraLinksAppended(t *testing.T) {
outDir := t.TempDir()
result := &parser.ParseResult{
Rules: []*parser.ValeRule{makeRule("Avoid", "existence", "error")},
}
cfg := &config.Config{
Title: "Test Guide",
BaseURL: "/",
Resources: config.ResourcesConfig{
ExtraLinks: []config.ResourceLink{
{Label: "Custom", URL: "https://example.com", Description: "A custom link"},
},
},
}

if err := generator.GenerateSite(result, cfg, outDir); err != nil {
t.Fatalf("GenerateSite: %v", err)
}

data := readFile(t, filepath.Join(outDir, "data", "site.json"))
var stats map[string]interface{}
if err := json.Unmarshal([]byte(data), &stats); err != nil {
t.Fatalf("site.json is not valid JSON: %v", err)
}

links := stats["resource_links"].([]interface{})
if len(links) != 4 {
t.Fatalf("resource_links length = %d, want 4 (3 defaults + 1 custom)", len(links))
}

last := links[3].(map[string]interface{})
Comment on lines +1149 to +1154
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test uses an unchecked type assertion (stats["resource_links"].([]interface{})), which can panic and obscure the real failure if resource_links is missing or not an array. Mirror the prior test’s checked assertion and fail with a helpful message.

Suggested change
links := stats["resource_links"].([]interface{})
if len(links) != 4 {
t.Fatalf("resource_links length = %d, want 4 (3 defaults + 1 custom)", len(links))
}
last := links[3].(map[string]interface{})
rawLinks, ok := stats["resource_links"]
if !ok {
t.Fatalf("site.json missing 'resource_links' key: %#v", stats)
}
links, ok := rawLinks.([]interface{})
if !ok {
t.Fatalf("site.json 'resource_links' has unexpected type %T (value: %#v)", rawLinks, rawLinks)
}
if len(links) != 4 {
t.Fatalf("resource_links length = %d, want 4 (3 defaults + 1 custom)", len(links))
}
lastRaw := links[3]
last, ok := lastRaw.(map[string]interface{})
if !ok {
t.Fatalf("resource_links[3] has unexpected type %T (value: %#v)", lastRaw, lastRaw)
}

Copilot uses AI. Check for mistakes.
if last["label"] != "Custom" {
t.Errorf("last link label = %v, want 'Custom'", last["label"])
}
}

func TestGenerateSite_ResourcesPage_Created(t *testing.T) {
outDir := t.TempDir()
result := &parser.ParseResult{
Rules: []*parser.ValeRule{makeRule("Avoid", "existence", "error")},
}
cfg := &config.Config{Title: "Test Guide", BaseURL: "/"}

if err := generator.GenerateSite(result, cfg, outDir); err != nil {
t.Fatalf("GenerateSite: %v", err)
}

indexPath := filepath.Join(outDir, "content", "resources", "_index.md")
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
t.Error("expected content/resources/_index.md to exist")
}

content := readFile(t, indexPath)
if !strings.Contains(content, "type: resources") {
t.Errorf("resources _index.md missing type: resources: %s", content)
}
if !strings.Contains(content, "title: Resources") {
t.Errorf("resources _index.md missing title: %s", content)
}
}

func TestGenerateSite_ResourcesPage_Suppressed(t *testing.T) {
outDir := t.TempDir()
disabled := false
result := &parser.ParseResult{
Rules: []*parser.ValeRule{makeRule("Avoid", "existence", "error")},
}
cfg := &config.Config{
Title: "Test Guide",
BaseURL: "/",
Resources: config.ResourcesConfig{Enabled: &disabled},
}

if err := generator.GenerateSite(result, cfg, outDir); err != nil {
t.Fatalf("GenerateSite: %v", err)
}

// Page should not exist
indexPath := filepath.Join(outDir, "content", "resources", "_index.md")
if _, err := os.Stat(indexPath); !os.IsNotExist(err) {
t.Error("content/resources/_index.md should not exist when resources.enabled is false")
}

// But site.json should still have resource_links (footer needs them)
data := readFile(t, filepath.Join(outDir, "data", "site.json"))
var stats map[string]interface{}
if err := json.Unmarshal([]byte(data), &stats); err != nil {
t.Fatalf("site.json is not valid JSON: %v", err)
}
if _, ok := stats["resource_links"]; !ok {
t.Error("site.json should still contain resource_links even when page is disabled")
}
}

// ── CountPages ────────────────────────────────────────────────────────────────

func TestCountPages_NilTree(t *testing.T) {
Expand Down
4 changes: 3 additions & 1 deletion internal/generator/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type siteStats struct {
ByCategory map[string]int `json:"by_category"`
GuidelinesCount int `json:"guidelines_count,omitempty"`
GuidelinesSectionTitle string `json:"guidelines_section_title,omitempty"`
ResourceLinks []resourceLink `json:"resource_links"`
}

// generateHomepageIndex writes content/_index.md.
Expand Down Expand Up @@ -95,7 +96,7 @@ func generateRulesIndex(rules []*parser.ValeRule, rulesDir string) error {
}

// generateSiteJSON writes data/site.json with aggregated statistics.
func generateSiteJSON(rules []*parser.ValeRule, guidelinesCount int, sectionTitle string, dataDir string) error {
func generateSiteJSON(rules []*parser.ValeRule, guidelinesCount int, sectionTitle string, links []resourceLink, dataDir string) error {
byType, bySeverity, byCategory := aggregateCounts(rules)

stats := siteStats{
Expand All @@ -104,6 +105,7 @@ func generateSiteJSON(rules []*parser.ValeRule, guidelinesCount int, sectionTitl
BySeverity: bySeverity,
ByCategory: byCategory,
GuidelinesCount: guidelinesCount,
ResourceLinks: links,
}
if sectionTitle != "" {
stats.GuidelinesSectionTitle = sectionTitle
Expand Down
Loading
Loading