summaryrefslogtreecommitdiffstats
path: root/internal/cms/check.go
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-07-07 16:24:28 +0200
committerGab Virebent <gabriel1@virebent.art>2026-07-07 16:24:28 +0200
commit37f156c30e2a2b01c840d39f0dd2bdbf811732d6 (patch)
treee2ce84dc7f88ce0eb312899448f91b7bb93ca8a3 /internal/cms/check.go
downloadgemcms-main.tar.gz
gemcms-main.tar.xz
gemcms-main.zip
Initial commitHEADmain
Diffstat (limited to 'internal/cms/check.go')
-rw-r--r--internal/cms/check.go162
1 files changed, 162 insertions, 0 deletions
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)
+}