diff --git a/README.md b/README.md index 182205b..926e6c5 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: diff --git a/examples/starter-package/rulebound.yml b/examples/starter-package/rulebound.yml index 18a1137..47e8655 100644 --- a/examples/starter-package/rulebound.yml +++ b/examples/starter-package/rulebound.yml @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index c3140d5..e98c1bb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. @@ -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 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c8946e9..8552fde 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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 @@ -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) @@ -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") + } } diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 5b457e6..a1fb550 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -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 } diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go index 66e9e9a..de92d8f 100644 --- a/internal/generator/generator_test.go +++ b/internal/generator/generator_test.go @@ -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" { + 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{}) + 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) { diff --git a/internal/generator/index.go b/internal/generator/index.go index 769caae..87f172b 100644 --- a/internal/generator/index.go +++ b/internal/generator/index.go @@ -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. @@ -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{ @@ -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 diff --git a/internal/generator/resources.go b/internal/generator/resources.go new file mode 100644 index 0000000..f5a3594 --- /dev/null +++ b/internal/generator/resources.go @@ -0,0 +1,84 @@ +package generator + +import ( + "fmt" + "os" + "path/filepath" + + "go.yaml.in/yaml/v3" + + "github.com/armstrongl/rulebound/internal/config" +) + +// resourceLink is the JSON-serializable form of a resource link for site.json. +type resourceLink struct { + Label string `json:"label"` + URL string `json:"url"` + Description string `json:"description,omitempty"` + Footer bool `json:"footer"` +} + +// defaultResourceLinks returns the hardcoded Vale ecosystem links. +func defaultResourceLinks() []resourceLink { + return []resourceLink{ + { + Label: "Vale", + URL: "https://vale.sh", + Description: "A linter for prose — write with style.", + Footer: true, + }, + { + Label: "Vale Studio", + URL: "https://studio.vale.sh", + Description: "Test Vale rules in the browser.", + Footer: true, + }, + { + Label: "Vale on GitHub", + URL: "https://github.com/vale-cli/vale", + Description: "Source code, issues, and releases.", + Footer: false, + }, + } +} + +// buildResourceLinks merges defaults with extra links from config. +func buildResourceLinks(cfg *config.Config) []resourceLink { + links := defaultResourceLinks() + for _, extra := range cfg.Resources.ExtraLinks { + links = append(links, resourceLink{ + Label: extra.Label, + URL: extra.URL, + Description: extra.Description, + Footer: false, + }) + } + return links +} + +// resourcesIndexData is the frontmatter for content/resources/_index.md. +type resourcesIndexData struct { + Title string `yaml:"title"` + Type string `yaml:"type"` +} + +// generateResourcesPage writes content/resources/_index.md. +func generateResourcesPage(outputDir string) error { + resourcesDir := filepath.Join(outputDir, "content", "resources") + if err := os.MkdirAll(resourcesDir, 0o755); err != nil { + return fmt.Errorf("creating resources directory: %w", err) + } + + data := resourcesIndexData{Title: "Resources", Type: "resources"} + out, err := yaml.Marshal(data) + if err != nil { + return fmt.Errorf("marshaling resources index: %w", err) + } + + content := "---\n" + string(out) + "---\n" + path := filepath.Join(resourcesDir, "_index.md") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("writing resources/_index.md: %w", err) + } + return nil +} diff --git a/internal/hugo/theme/layouts/_default/baseof.html b/internal/hugo/theme/layouts/_default/baseof.html index adc6055..9ac1d73 100644 --- a/internal/hugo/theme/layouts/_default/baseof.html +++ b/internal/hugo/theme/layouts/_default/baseof.html @@ -47,7 +47,12 @@ diff --git a/internal/hugo/theme/layouts/resources/list.html b/internal/hugo/theme/layouts/resources/list.html new file mode 100644 index 0000000..f201ed6 --- /dev/null +++ b/internal/hugo/theme/layouts/resources/list.html @@ -0,0 +1,24 @@ +{{- define "main" -}} +{{- $data := index hugo.Data "site" -}} + +
Tools, documentation, and community projects from the Vale ecosystem.
+ + {{- with $data.resource_links -}} +