package cms import ( "fmt" "os" "path" "path/filepath" "strings" ) func Check(root string) (CheckReport, error) { project, err := LoadProject(root) if err != nil { return CheckReport{}, err } var report CheckReport if project.Config.Title == "" { report.add("ERROR", "capsule.toml", "missing title") } if len(project.Docs) == 0 { report.add("ERROR", "content", "no .gmi documents found") } if !hasPage(project.Docs, "index") { report.add("WARN", "content/pages", "no index page; build will generate one") } seen := map[string]string{} expected := expectedURLs(project) for _, doc := range project.Docs { if doc.Slug == "" { report.add("ERROR", doc.SourcePath, "missing slug") } key := doc.Type + ":" + doc.Section + ":" + doc.Slug if prev, ok := seen[key]; ok { report.add("ERROR", doc.SourcePath, "duplicate output with "+prev) } seen[key] = doc.SourcePath checkGemtextLinks(&report, doc, expected) } for name, menu := range project.Menus { for i, item := range menu.Items { pathName := filepath.Join("site/menus", name+".toml") if item.Label == "" { report.add("ERROR", pathName, fmt.Sprintf("menu item %d has empty label", i+1)) } if item.URL == "" { report.add("ERROR", pathName, fmt.Sprintf("menu item %d has empty URL", i+1)) continue } if !isExternalLink(item.URL) { normalized := normalizeLocalURL("/", item.URL) if !expected[normalized] { report.add("WARN", pathName, fmt.Sprintf("menu item %d local URL may not resolve: %s", i+1, item.URL)) } } } } for name, widget := range project.Widgets { pathName := filepath.Join("site/widgets", name+".toml") if widget.Type == "" { report.add("ERROR", pathName, "missing widget type") } if widget.Type == "recent" && widget.Section == "" { report.add("ERROR", pathName, "recent widget requires section") } } return report, nil } func (r *CheckReport) add(level, pathName, message string) { r.Issues = append(r.Issues, Issue{Level: level, Path: pathName, Message: message}) } func hasPage(docs []Document, slug string) bool { for _, doc := range docs { if doc.Type == "page" && doc.Slug == slug { return true } } return false } func expectedURLs(project Project) map[string]bool { expected := map[string]bool{ "/": true, "/index.gmi": true, } for _, doc := range project.Docs { if doc.Draft { continue } expected[docURL(doc)] = true } for section := range articlesBySection(project.Docs) { expected["/"+section+"/"] = true expected["/"+section+"/index.gmi"] = true } assetRoot := filepath.Join(project.Root, "assets") filepath.WalkDir(assetRoot, func(pathName string, entry os.DirEntry, err error) error { if err != nil || entry.IsDir() { return nil } rel, err := filepath.Rel(assetRoot, pathName) if err != nil { return nil } expected["/"+filepath.ToSlash(rel)] = true return nil }) return expected } func checkGemtextLinks(report *CheckReport, doc Document, expected map[string]bool) { base := path.Dir(docURL(doc)) if base == "." { base = "/" } for lineNo, line := range strings.Split(doc.Body, "\n") { trimmed := strings.TrimSpace(line) if !strings.HasPrefix(trimmed, "=>") { continue } target := strings.TrimSpace(strings.TrimPrefix(trimmed, "=>")) if target == "" { report.add("ERROR", doc.SourcePath, fmt.Sprintf("line %d: empty Gemtext link", lineNo+1)) continue } target = strings.Fields(target)[0] if isExternalLink(target) || strings.HasPrefix(target, "#") { continue } normalized := normalizeLocalURL(base, target) if !expected[normalized] { report.add("WARN", doc.SourcePath, fmt.Sprintf("line %d: local link may not resolve: %s", lineNo+1, target)) } } } func isExternalLink(target string) bool { return strings.Contains(target, "://") || strings.HasPrefix(target, "mailto:") } func normalizeLocalURL(base, target string) string { if strings.HasPrefix(target, "/") { if strings.HasSuffix(target, "/") { return target } return path.Clean(target) } joined := path.Join(base, target) if strings.HasSuffix(target, "/") { return "/" + strings.TrimPrefix(joined, "/") + "/" } if strings.HasPrefix(joined, "/") { return path.Clean(joined) } return "/" + path.Clean(joined) }