From 37f156c30e2a2b01c840d39f0dd2bdbf811732d6 Mon Sep 17 00:00:00 2001 From: Gab Virebent Date: Tue, 7 Jul 2026 16:24:28 +0200 Subject: Initial commit --- internal/cms/check.go | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 internal/cms/check.go (limited to 'internal/cms/check.go') diff --git a/internal/cms/check.go b/internal/cms/check.go new file mode 100644 index 0000000..2e3a921 --- /dev/null +++ b/internal/cms/check.go @@ -0,0 +1,162 @@ +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) +} -- cgit v1.2.3