// Validator for README.md structure in github.com/dariubs/gobooks. // // Checks: required sections, TOC link validity, no year prefixes in titles, // book metadata (*Last published*, *Authors*), cover images exist, covers linked. // // Usage: go run . [path/to/README.md] // go run . # uses README.md at repo root (../../README.md) package main import ( "fmt" "os" "path/filepath" "regexp" "strings" ) func main() { readmePath := filepath.Join("..", "..", "README.md") if len(os.Args) > 1 { readmePath = os.Args[1] } content, err := os.ReadFile(readmePath) if err != nil { fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err) os.Exit(1) } lines := strings.Split(string(content), "\n") errors := []string{} warnings := []string{} // 1. Check main sections exist requiredSections := []string{"Starter Books", "Advanced Books", "Web Development", "Resources"} foundSections := extractSections(lines) for _, sec := range requiredSections { if !foundSections[sec] { errors = append(errors, fmt.Sprintf("Missing required section: ## %s", sec)) } } // Contributing and License may use ## or ==== format hasContributing := foundSections["Contributing"] hasLicense := foundSections["License"] for i, line := range lines { if strings.TrimSpace(line) == "Contributing" && i+1 < len(lines) && strings.HasPrefix(lines[i+1], "====") { hasContributing = true } if strings.TrimSpace(line) == "License" && i+1 < len(lines) && strings.HasPrefix(lines[i+1], "====") { hasLicense = true } } if !hasContributing { errors = append(errors, "Missing required section: Contributing") } if !hasLicense { errors = append(errors, "Missing required section: License") } // 2. Extract TOC links and section headers tocLinks := extractTOCLinks(lines) headers := extractHeaders(lines) // Add Contributing/License anchors when using ==== format if hasContributing { headers["contributing"] = true } if hasLicense { headers["license"] = true } // 3. Verify TOC links point to existing headers for link, anchor := range tocLinks { if !headers[anchor] { errors = append(errors, fmt.Sprintf("TOC link points to non-existent anchor: [%s](#%s)", truncate(link, 50), anchor)) } } // 4. Check section headers have no year prefix (### 2020 - [Title] is wrong) yearPrefix := regexp.MustCompile(`^###\s+\d{4}\s+-\s+`) for i, line := range lines { if yearPrefix.MatchString(line) { errors = append(errors, fmt.Sprintf("Line %d: Book title should not have year prefix: %s", i+1, strings.TrimSpace(line)[:min(60, len(strings.TrimSpace(line)))]+"...")) } } // 5. Check book entries have required metadata (*Last published* and *Authors*) - only in book sections bookBlocks := extractBookBlocks(lines) inResourceSection := false for i, line := range lines { if strings.TrimSpace(line) == "## Resources" { inResourceSection = true break } _ = i } resourceTitles := map[string]bool{"A tour of Go": true, "Video: Learn Go Syntax in one video": true, "Tutorials: Go by Example": true, "Go Fundamentals Video Training": true, "More Books on the Go Wiki": true, "TutorialEdge.net Course": true, "Coursera Specialization: Programming with Go": true, "Course: Understand Go's In-Depth Mechanics": true, "Course: Mastering Go Programming": true, "Course: Web Development with Google's Go Programming Language": true, "Golangbot.com Articles": true, "Tuxerrante repo on go exercises": true} for title, block := range bookBlocks { if resourceTitles[title] || inResourceSection && (strings.Contains(title, "tour") || strings.Contains(title, "Video") || strings.Contains(title, "Tutorial") || strings.Contains(title, "Course") || strings.Contains(title, "Articles") || strings.Contains(title, "Wiki")) { continue } hasLastPublished := strings.Contains(block, "*Last published*") hasAuthors := strings.Contains(block, "*Authors:*") if !hasLastPublished { warnings = append(warnings, fmt.Sprintf("Book '%s' missing *Last published*", truncate(title, 50))) } if !hasAuthors { warnings = append(warnings, fmt.Sprintf("Book '%s' missing *Authors:*", truncate(title, 50))) } } // 6. Check cover images exist coverRefs := extractCoverRefs(lines) for _, ref := range coverRefs { if strings.HasPrefix(ref, "http") { continue // external URL, skip } path := filepath.Join(filepath.Dir(readmePath), ref) if _, err := os.Stat(path); os.IsNotExist(err) { errors = append(errors, fmt.Sprintf("Cover image not found: %s", ref)) } } // 7. Check cover images are linked (wrapped in ) unlinkedCovers := findUnlinkedCovers(lines) for _, img := range unlinkedCovers { warnings = append(warnings, fmt.Sprintf("Cover image not linked to book: %s", img)) } // Report if len(errors) > 0 { fmt.Println("❌ ERRORS:") for _, e := range errors { fmt.Println(" ", e) } } if len(warnings) > 0 { fmt.Println("\n⚠️ WARNINGS:") for _, w := range warnings { fmt.Println(" ", w) } } if len(errors) > 0 { os.Exit(1) } if len(warnings) > 0 { fmt.Println("\n✓ Structure OK (with warnings)") } else { fmt.Println("\n✓ Structure OK") } } func extractSections(lines []string) map[string]bool { m := make(map[string]bool) r := regexp.MustCompile(`^##\s+(.+)$`) for _, line := range lines { if match := r.FindStringSubmatch(line); match != nil { m[strings.TrimSpace(match[1])] = true } } return m } func extractHeaders(lines []string) map[string]bool { m := make(map[string]bool) r := regexp.MustCompile(`^#{1,6}\s+(.+)$`) for _, line := range lines { if match := r.FindStringSubmatch(line); match != nil { text := strings.TrimSpace(match[1]) // Extract link text from [Title](url) or [Title](url) *Free* linkMatch := regexp.MustCompile(`^\[([^\]]+)\][^\)]*\)`).FindStringSubmatch(text) if linkMatch != nil { text = linkMatch[1] } // Remove *Free* suffix text = regexp.MustCompile(`\s*\*Free\*\s*$`).ReplaceAllString(text, "") text = strings.TrimSpace(text) anchor := toAnchor(text) m[anchor] = true // Add -free variant (TOC often uses this for free books even when header omits *Free*) m[anchor+"-free"] = true } } return m } func toAnchor(s string) string { s = strings.ToLower(s) s = regexp.MustCompile(`[^a-z0-9\s-]`).ReplaceAllString(s, "") s = regexp.MustCompile(`\s+`).ReplaceAllString(s, "-") return strings.Trim(s, "-") } func extractTOCLinks(lines []string) map[string]string { m := make(map[string]string) r := regexp.MustCompile(`^\s*-\s+\[([^\]]+)\]\(#([^\)]+)\)`) for _, line := range lines { if match := r.FindStringSubmatch(line); match != nil { title := strings.TrimSpace(match[1]) anchor := strings.TrimSpace(match[2]) m[title] = anchor } } return m } func extractBookBlocks(lines []string) map[string]string { m := make(map[string]string) bookR := regexp.MustCompile(`^###\s+\[([^\]]+)\].*$`) var currentTitle string var currentBlock strings.Builder for _, line := range lines { if match := bookR.FindStringSubmatch(line); match != nil { if currentTitle != "" { m[currentTitle] = currentBlock.String() } currentTitle = match[1] currentBlock.Reset() currentBlock.WriteString(line) currentBlock.WriteString("\n") } else if currentTitle != "" && (strings.HasPrefix(line, "###") || strings.HasPrefix(line, "##")) { m[currentTitle] = currentBlock.String() currentTitle = "" currentBlock.Reset() } else if currentTitle != "" && line != "-----" { currentBlock.WriteString(line) currentBlock.WriteString("\n") } } if currentTitle != "" { m[currentTitle] = currentBlock.String() } return m } func extractCoverRefs(lines []string) []string { var refs []string r := regexp.MustCompile(`src="(gobooks/covers/[^"]+)"`) for _, line := range lines { if match := r.FindStringSubmatch(line); match != nil { refs = append(refs, match[1]) } } // Also check gobooks/cover/ path (typo) r2 := regexp.MustCompile(`src="(gobooks/cover/[^"]+)"`) for _, line := range lines { if match := r2.FindStringSubmatch(line); match != nil { refs = append(refs, match[1]) } } return refs } func findUnlinkedCovers(lines []string) []string { var unlinked []string r := regexp.MustCompile(` or markdown link [](url) if (strings.Contains(line, " max { return s[:max] + "..." } return s } func min(a, b int) int { if a < b { return a } return b }