diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-07-07 16:24:28 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-07-07 16:24:28 +0200 |
| commit | 37f156c30e2a2b01c840d39f0dd2bdbf811732d6 (patch) | |
| tree | e2ce84dc7f88ce0eb312899448f91b7bb93ca8a3 /internal/cms | |
| download | gemcms-main.tar.gz gemcms-main.tar.xz gemcms-main.zip | |
Diffstat (limited to 'internal/cms')
| -rw-r--r-- | internal/cms/build.go | 274 | ||||
| -rw-r--r-- | internal/cms/check.go | 162 | ||||
| -rw-r--r-- | internal/cms/cms_test.go | 186 | ||||
| -rw-r--r-- | internal/cms/config.go | 40 | ||||
| -rw-r--r-- | internal/cms/content.go | 166 | ||||
| -rw-r--r-- | internal/cms/import.go | 82 | ||||
| -rw-r--r-- | internal/cms/markdown.go | 43 | ||||
| -rw-r--r-- | internal/cms/menu.go | 193 | ||||
| -rw-r--r-- | internal/cms/project.go | 407 | ||||
| -rw-r--r-- | internal/cms/section.go | 54 | ||||
| -rw-r--r-- | internal/cms/simpletoml.go | 172 | ||||
| -rw-r--r-- | internal/cms/slug.go | 51 | ||||
| -rw-r--r-- | internal/cms/status.go | 50 | ||||
| -rw-r--r-- | internal/cms/types.go | 174 | ||||
| -rw-r--r-- | internal/cms/widget.go | 106 |
15 files changed, 2160 insertions, 0 deletions
diff --git a/internal/cms/build.go b/internal/cms/build.go new file mode 100644 index 0000000..c434611 --- /dev/null +++ b/internal/cms/build.go @@ -0,0 +1,274 @@ +package cms + +import ( + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" +) + +func Build(root string) (BuildReport, error) { + project, err := LoadProject(root) + if err != nil { + return BuildReport{}, err + } + outDir := filepath.Join(root, "public") + if err := os.RemoveAll(outDir); err != nil { + return BuildReport{}, err + } + if err := os.MkdirAll(outDir, 0o755); err != nil { + return BuildReport{}, err + } + + report := BuildReport{OutputDir: outDir} + if err := copyAssets(filepath.Join(root, "assets"), outDir, &report); err != nil { + return BuildReport{}, err + } + + hasIndex := false + for _, doc := range project.Docs { + if doc.Draft { + continue + } + if doc.Type == "page" && doc.Slug == "index" { + hasIndex = true + } + rel := outputRel(doc) + rendered := renderDocument(project, doc) + if err := writeFile(filepath.Join(outDir, filepath.FromSlash(rel)), []byte(rendered)); err != nil { + return BuildReport{}, err + } + report.FilesWritten++ + } + if !hasIndex { + rendered := renderGeneratedIndex(project) + if err := writeFile(filepath.Join(outDir, "index.gmi"), []byte(rendered)); err != nil { + return BuildReport{}, err + } + report.FilesWritten++ + } + for section, docs := range articlesBySection(project.Docs) { + rendered := renderSectionIndex(project, section, docs) + if err := writeFile(filepath.Join(outDir, section, "index.gmi"), []byte(rendered)); err != nil { + return BuildReport{}, err + } + report.FilesWritten++ + } + + return report, nil +} + +func renderDocument(project Project, doc Document) string { + body := expandBlocks(project, doc.Body) + return strings.TrimRight(body, "\n") + "\n" +} + +func renderGeneratedIndex(project Project) string { + var b strings.Builder + b.WriteString("# " + project.Config.Title + "\n\n") + b.WriteString(renderMenu(project.Menus["top"])) + b.WriteString("\n## Latest articles\n\n") + b.WriteString(renderWidget(project, Widget{Name: "recent-articles", Type: "recent", Section: "articles", Limit: 5})) + return strings.TrimRight(b.String(), "\n") + "\n" +} + +func renderSectionIndex(project Project, section string, docs []Document) string { + title := titleize(section) + if cfg, ok := project.Sections[section]; ok { + title = cfg.Title + } + var b strings.Builder + b.WriteString("# " + title + "\n\n") + if menu, ok := project.Menus["top"]; ok { + b.WriteString(renderMenu(menu)) + b.WriteString("\n") + } + for _, doc := range docs { + b.WriteString("=> " + docURL(doc) + " " + doc.Date + " " + doc.Title + "\n") + } + return strings.TrimRight(b.String(), "\n") + "\n" +} + +func expandBlocks(project Project, body string) string { + var out []string + for _, line := range strings.Split(body, "\n") { + name, arg, ok := parseBlock(line) + if !ok { + out = append(out, line) + continue + } + switch name { + case "menu": + out = append(out, strings.TrimRight(renderMenu(project.Menus[arg]), "\n")) + case "widget": + widget, ok := project.Widgets[arg] + if !ok { + out = append(out, "Missing widget: "+arg) + continue + } + out = append(out, strings.TrimRight(renderWidget(project, widget), "\n")) + default: + out = append(out, line) + } + } + return strings.Join(out, "\n") +} + +func parseBlock(line string) (string, string, bool) { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "{{") || !strings.HasSuffix(line, "}}") { + return "", "", false + } + line = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "{{"), "}}")) + parts := strings.Fields(line) + if len(parts) != 2 { + return "", "", false + } + return parts[0], parts[1], true +} + +func renderMenu(menu Menu) string { + if len(menu.Items) == 0 { + return "" + } + var b strings.Builder + for _, item := range menu.Items { + b.WriteString("=> " + item.URL + " " + item.Label + "\n") + } + return b.String() +} + +func renderWidget(project Project, widget Widget) string { + switch widget.Type { + case "recent": + return renderRecentWidget(project, widget) + default: + return "Unsupported widget: " + widget.Type + "\n" + } +} + +func renderRecentWidget(project Project, widget Widget) string { + var docs []Document + for _, doc := range project.Docs { + if doc.Type == "article" && !doc.Draft && doc.Section == widget.Section { + docs = append(docs, doc) + } + } + sortArticles(docs) + if widget.Limit > 0 && len(docs) > widget.Limit { + docs = docs[:widget.Limit] + } + if len(docs) == 0 { + return "No articles yet.\n" + } + var b strings.Builder + for _, doc := range docs { + b.WriteString("=> " + docURL(doc) + " " + doc.Date + " " + doc.Title + "\n") + } + return b.String() +} + +func articlesBySection(docs []Document) map[string][]Document { + sections := map[string][]Document{} + for _, doc := range docs { + if doc.Type != "article" || doc.Draft { + continue + } + sections[doc.Section] = append(sections[doc.Section], doc) + } + for section := range sections { + sortArticles(sections[section]) + } + return sections +} + +func sortArticles(docs []Document) { + sort.Slice(docs, func(i, j int) bool { + if docs[i].Date != docs[j].Date { + return docs[i].Date > docs[j].Date + } + return docs[i].Slug < docs[j].Slug + }) +} + +func outputRel(doc Document) string { + if doc.Type == "page" { + if doc.Slug == "index" || doc.Slug == "" { + return "index.gmi" + } + return doc.Slug + ".gmi" + } + section := doc.Section + if section == "" { + section = "articles" + } + return path.Join(section, doc.Slug+".gmi") +} + +func docURL(doc Document) string { + rel := outputRel(doc) + if rel == "index.gmi" { + return "/" + } + return "/" + rel +} + +func copyAssets(src, outDir string, report *BuildReport) error { + if _, err := os.Stat(src); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + return filepath.WalkDir(src, func(pathName string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + rel, err := filepath.Rel(src, pathName) + if err != nil { + return err + } + dest := filepath.Join(outDir, filepath.FromSlash(filepath.ToSlash(rel))) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + if err := copyFile(pathName, dest); err != nil { + return err + } + report.FilesWritten++ + return nil + }) +} + +func copyFile(src, dest string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dest) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + if err := out.Close(); err != nil { + return err + } + return nil +} + +func debugProject(project Project) string { + return fmt.Sprintf("%d docs, %d menus, %d widgets", len(project.Docs), len(project.Menus), len(project.Widgets)) +} 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) +} diff --git a/internal/cms/cms_test.go b/internal/cms/cms_test.go new file mode 100644 index 0000000..73d074f --- /dev/null +++ b/internal/cms/cms_test.go @@ -0,0 +1,186 @@ +package cms + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCreateSiteArticleAndBuild(t *testing.T) { + tmp := t.TempDir() + root, err := CreateSite(tmp, SiteOptions{Name: "my-capsule", Title: "My Capsule", Host: "gemini.example.org", Language: "en"}) + if err != nil { + t.Fatalf("CreateSite: %v", err) + } + if _, err := CreateArticle(root, ArticleOptions{Title: "Hello Gemini", Section: "journal", Tags: []string{"gemini"}}); err != nil { + t.Fatalf("CreateArticle: %v", err) + } + if _, err := CreateSection(root, SectionOptions{Name: "journal", Title: "Journal"}); err != nil { + t.Fatalf("CreateSection: %v", err) + } + report, err := Build(root) + if err != nil { + t.Fatalf("Build: %v", err) + } + if report.FilesWritten < 3 { + t.Fatalf("expected at least 3 files written, got %d", report.FilesWritten) + } + assertFileContains(t, filepath.Join(root, "public/index.gmi"), "My Capsule") + assertFileContains(t, filepath.Join(root, "public/journal/hello-gemini.gmi"), "# Hello Gemini") + assertFileContains(t, filepath.Join(root, "public/journal/index.gmi"), "=> /journal/hello-gemini.gmi") +} + +func TestMenuAndWidgetShortcodes(t *testing.T) { + tmp := t.TempDir() + root, err := CreateSite(tmp, SiteOptions{Name: "capsule", Title: "Capsule"}) + if err != nil { + t.Fatalf("CreateSite: %v", err) + } + if _, err := CreatePage(root, PageOptions{Slug: "about", Title: "About"}); err != nil { + t.Fatalf("CreatePage: %v", err) + } + if err := AddMenuItem(root, MenuItemOptions{Menu: "top", Kind: "page", Target: "about", Label: "About"}); err != nil { + t.Fatalf("AddMenuItem: %v", err) + } + if _, err := CreateArticle(root, ArticleOptions{Title: "First Post"}); err != nil { + t.Fatalf("CreateArticle: %v", err) + } + if _, err := Build(root); err != nil { + t.Fatalf("Build: %v", err) + } + assertFileContains(t, filepath.Join(root, "public/index.gmi"), "=> /about.gmi About") + assertFileContains(t, filepath.Join(root, "public/index.gmi"), "=> /articles/first-post.gmi") +} + +func TestAddMarkdownArticle(t *testing.T) { + tmp := t.TempDir() + root, err := CreateSite(tmp, SiteOptions{Name: "capsule", Title: "Capsule"}) + if err != nil { + t.Fatalf("CreateSite: %v", err) + } + source := filepath.Join(tmp, "article.md") + if err := os.WriteFile(source, []byte("# Imported Article\n\n- one\n\n[Gemini](gemini://example.org)\n"), 0o644); err != nil { + t.Fatalf("write source: %v", err) + } + doc, err := AddFile(root, AddOptions{ + Path: source, + Section: "journal", + Tags: []string{"gemini"}, + Draft: true, + Menu: "top", + }) + if err != nil { + t.Fatalf("AddFile: %v", err) + } + if doc.Type != "article" { + t.Fatalf("expected article, got %q", doc.Type) + } + assertFileContains(t, doc.SourcePath, "section = \"journal\"") + assertFileContains(t, doc.SourcePath, "tags = [\"gemini\"]") + assertFileContains(t, doc.SourcePath, "draft = true") + assertFileContains(t, doc.SourcePath, "* one") + assertFileContains(t, doc.SourcePath, "=> gemini://example.org Gemini") +} + +func TestAddDirectoryImportsContentFiles(t *testing.T) { + tmp := t.TempDir() + root, err := CreateSite(tmp, SiteOptions{Name: "capsule", Title: "Capsule"}) + if err != nil { + t.Fatalf("CreateSite: %v", err) + } + sourceDir := filepath.Join(tmp, "source") + if err := os.MkdirAll(filepath.Join(sourceDir, "pages"), 0o755); err != nil { + t.Fatalf("mkdir source: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "post.md"), []byte("---\ntitle: YAML Title\ntags: gemini, cms\n---\n\nBody\n"), 0o644); err != nil { + t.Fatalf("write post: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "pages/about.gmi"), []byte("+++\ntitle = \"About\"\ntype = \"page\"\nslug = \"about\"\n+++\n\n# About\n"), 0o644); err != nil { + t.Fatalf("write page: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "ignore.txt"), []byte("ignore"), 0o644); err != nil { + t.Fatalf("write ignored file: %v", err) + } + + report, err := AddPath(root, AddOptions{Path: sourceDir, Section: "journal"}) + if err != nil { + t.Fatalf("AddPath: %v", err) + } + if len(report.Documents) != 2 { + t.Fatalf("expected 2 imported documents, got %d", len(report.Documents)) + } + article := findDocument(t, report.Documents, "YAML Title") + assertFileContains(t, article.SourcePath, "title = \"YAML Title\"") + assertFileContains(t, article.SourcePath, "tags = [\"gemini\", \"cms\"]") + assertFileContains(t, article.SourcePath, "section = \"journal\"") + assertFileContains(t, filepath.Join(root, "content/pages/about.gmi"), "title = \"About\"") +} + +func TestStatusReportCountsContentAndIssues(t *testing.T) { + tmp := t.TempDir() + root, err := CreateSite(tmp, SiteOptions{Name: "capsule", Title: "Capsule"}) + if err != nil { + t.Fatalf("CreateSite: %v", err) + } + if _, err := CreateArticle(root, ArticleOptions{Title: "Draft Post", Draft: true}); err != nil { + t.Fatalf("CreateArticle: %v", err) + } + if _, err := Build(root); err != nil { + t.Fatalf("Build: %v", err) + } + status, err := Status(root) + if err != nil { + t.Fatalf("Status: %v", err) + } + if status.Pages != 1 || status.Articles != 1 || status.Drafts != 1 { + t.Fatalf("unexpected status counts: %#v", status) + } + if status.Health() != "OK" { + t.Fatalf("expected OK health, got %s with %#v", status.Health(), status.Issues) + } + if status.PublicFiles == 0 { + t.Fatalf("expected public files to be counted") + } +} + +func TestCheckReportsMalformedGemtextLink(t *testing.T) { + tmp := t.TempDir() + root, err := CreateSite(tmp, SiteOptions{Name: "capsule", Title: "Capsule"}) + if err != nil { + t.Fatalf("CreateSite: %v", err) + } + pagePath := filepath.Join(root, "content/pages/bad.gmi") + if err := os.WriteFile(pagePath, []byte("+++\ntitle = \"Bad\"\ntype = \"page\"\nslug = \"bad\"\n+++\n\n=>\n"), 0o644); err != nil { + t.Fatalf("write bad page: %v", err) + } + report, err := Check(root) + if err != nil { + t.Fatalf("Check: %v", err) + } + if !report.HasErrors() { + t.Fatalf("expected check errors, got %#v", report.Issues) + } +} + +func assertFileContains(t *testing.T, path, want string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if !strings.Contains(string(data), want) { + t.Fatalf("%s does not contain %q\n%s", path, want, string(data)) + } +} + +func findDocument(t *testing.T, docs []Document, title string) Document { + t.Helper() + for _, doc := range docs { + if doc.Title == title { + return doc + } + } + t.Fatalf("document %q not found in %#v", title, docs) + return Document{} +} diff --git a/internal/cms/config.go b/internal/cms/config.go new file mode 100644 index 0000000..de34a5c --- /dev/null +++ b/internal/cms/config.go @@ -0,0 +1,40 @@ +package cms + +import ( + "fmt" + "os" + "path/filepath" +) + +func LoadConfig(root string) (Config, error) { + data, err := os.ReadFile(filepath.Join(root, "capsule.toml")) + if err != nil { + return Config{}, err + } + table, err := parseSimpleTable(string(data)) + if err != nil { + return Config{}, fmt.Errorf("parse capsule.toml: %w", err) + } + cfg := Config{ + Title: table.values["title"], + Host: table.values["host"], + Author: table.values["author"], + Language: table.values["language"], + Theme: table.values["theme"], + } + if cfg.Language == "" { + cfg.Language = "en" + } + if cfg.Theme == "" { + cfg.Theme = "default" + } + return cfg, nil +} + +func renderConfig(cfg Config) string { + return "title = " + formatString(cfg.Title) + "\n" + + "host = " + formatString(cfg.Host) + "\n" + + "author = " + formatString(cfg.Author) + "\n" + + "language = " + formatString(cfg.Language) + "\n" + + "theme = " + formatString(cfg.Theme) + "\n" +} diff --git a/internal/cms/content.go b/internal/cms/content.go new file mode 100644 index 0000000..f4b0343 --- /dev/null +++ b/internal/cms/content.go @@ -0,0 +1,166 @@ +package cms + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +func LoadDocuments(root string) ([]Document, error) { + contentRoot := filepath.Join(root, "content") + var docs []Document + if _, err := os.Stat(contentRoot); err != nil { + return nil, err + } + err := filepath.WalkDir(contentRoot, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || filepath.Ext(path) != ".gmi" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + doc, err := ParseDocument(path, string(data)) + if err != nil { + return err + } + docs = append(docs, doc) + return nil + }) + if err != nil { + return nil, err + } + sort.Slice(docs, func(i, j int) bool { + if docs[i].Type != docs[j].Type { + return docs[i].Type < docs[j].Type + } + if docs[i].Date != docs[j].Date { + return docs[i].Date > docs[j].Date + } + return docs[i].Slug < docs[j].Slug + }) + return docs, nil +} + +func ParseDocument(path, data string) (Document, error) { + meta, body, hasMeta := splitFrontmatter(data) + doc := Document{ + SourcePath: path, + Body: strings.TrimLeft(body, "\n"), + } + if hasMeta { + table, err := parseMetadata(meta) + if err != nil { + return Document{}, fmt.Errorf("%s: parse frontmatter: %w", path, err) + } + doc.Type = table.values["type"] + doc.Slug = table.values["slug"] + doc.Title = table.values["title"] + doc.Date = table.values["date"] + doc.Section = table.values["section"] + doc.Tags = table.lists["tags"] + doc.Draft = table.values["draft"] == "true" + } + if doc.Type == "" { + doc.Type = inferType(path) + } + if doc.Slug == "" { + doc.Slug = inferSlug(path) + } + if doc.Title == "" { + doc.Title = inferTitle(doc.Body, doc.Slug) + } + if doc.Type == "article" { + if doc.Section == "" { + doc.Section = "articles" + } + if doc.Date == "" { + doc.Date = inferDate(path) + } + } + return doc, nil +} + +func writeDocument(path string, doc Document) error { + var b strings.Builder + b.WriteString("+++\n") + b.WriteString("title = " + formatString(doc.Title) + "\n") + b.WriteString("type = " + formatString(doc.Type) + "\n") + b.WriteString("slug = " + formatString(doc.Slug) + "\n") + if doc.Date != "" { + b.WriteString("date = " + formatString(doc.Date) + "\n") + } + if doc.Section != "" { + b.WriteString("section = " + formatString(doc.Section) + "\n") + } + if len(doc.Tags) > 0 { + b.WriteString("tags = " + formatStringList(doc.Tags) + "\n") + } + if doc.Draft { + b.WriteString("draft = true\n") + } + b.WriteString("+++\n\n") + b.WriteString(strings.TrimLeft(doc.Body, "\n")) + return writeNewFile(path, []byte(b.String())) +} + +func splitFrontmatter(data string) (string, string, bool) { + data = strings.ReplaceAll(data, "\r\n", "\n") + delimiter := "" + switch { + case strings.HasPrefix(data, "+++\n"): + delimiter = "+++" + case strings.HasPrefix(data, "---\n"): + delimiter = "---" + default: + return "", data, false + } + rest := strings.TrimPrefix(data, delimiter+"\n") + idx := strings.Index(rest, "\n"+delimiter+"\n") + if idx < 0 { + return "", data, false + } + meta := rest[:idx] + body := rest[idx+len("\n"+delimiter+"\n"):] + return meta, body, true +} + +func inferType(path string) string { + clean := filepath.ToSlash(path) + if strings.Contains(clean, "/articles/") { + return "article" + } + return "page" +} + +func inferSlug(path string) string { + name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + if len(name) > len("2006-01-02-") && name[4] == '-' && name[7] == '-' && name[10] == '-' { + name = name[11:] + } + return slugify(name) +} + +func inferDate(path string) string { + name := filepath.Base(path) + if len(name) >= len("2006-01-02") && name[4] == '-' && name[7] == '-' { + return name[:10] + } + return "" +} + +func inferTitle(body, slug string) string { + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "# ") { + return strings.TrimSpace(strings.TrimPrefix(line, "# ")) + } + } + return titleize(slug) +} diff --git a/internal/cms/import.go b/internal/cms/import.go new file mode 100644 index 0000000..3b0d697 --- /dev/null +++ b/internal/cms/import.go @@ -0,0 +1,82 @@ +package cms + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +func AddPath(root string, opts AddOptions) (AddReport, error) { + sourcePath, err := expandPath(opts.Path) + if err != nil { + return AddReport{}, err + } + info, err := os.Stat(sourcePath) + if err != nil { + return AddReport{}, err + } + if !info.IsDir() { + doc, err := AddFile(root, opts) + if err != nil { + return AddReport{}, err + } + return AddReport{Documents: []Document{doc}}, nil + } + if opts.Title != "" || opts.Slug != "" { + return AddReport{}, errors.New("--title and --slug can only be used when importing one file") + } + + files, err := importableFiles(sourcePath) + if err != nil { + return AddReport{}, err + } + if len(files) == 0 { + return AddReport{}, fmt.Errorf("%s has no .md or .gmi files", sourcePath) + } + + report := AddReport{} + for _, file := range files { + fileOpts := opts + fileOpts.Path = file + doc, err := AddFile(root, fileOpts) + if err != nil { + return report, fmt.Errorf("import %s: %w", file, err) + } + report.Documents = append(report.Documents, doc) + } + return report, nil +} + +func importableFiles(root string) ([]string, error) { + var files []string + err := filepath.WalkDir(root, func(pathName string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if strings.HasPrefix(entry.Name(), ".") && pathName != root { + return filepath.SkipDir + } + return nil + } + if isImportableContent(pathName) { + files = append(files, pathName) + } + return nil + }) + sort.Strings(files) + return files, err +} + +func isImportableContent(pathName string) bool { + switch strings.ToLower(filepath.Ext(pathName)) { + case ".md", ".markdown", ".gmi", ".gemini": + return true + default: + return false + } +} diff --git a/internal/cms/markdown.go b/internal/cms/markdown.go new file mode 100644 index 0000000..49149b8 --- /dev/null +++ b/internal/cms/markdown.go @@ -0,0 +1,43 @@ +package cms + +import ( + "regexp" + "strings" +) + +var ( + standaloneMarkdownLink = regexp.MustCompile(`^\s*\[([^\]]+)\]\(([^)]+)\)\s*$`) + inlineMarkdownLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) +) + +func markdownToGemtext(body string) string { + var out []string + inFence := false + + for _, line := range strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```") { + inFence = !inFence + out = append(out, line) + continue + } + if inFence { + out = append(out, line) + continue + } + if match := standaloneMarkdownLink.FindStringSubmatch(line); match != nil { + out = append(out, "=> "+match[2]+" "+match[1]) + continue + } + if strings.HasPrefix(trimmed, "- ") { + indent := line[:strings.Index(line, "-")] + line = indent + "* " + strings.TrimPrefix(trimmed, "- ") + } + line = inlineMarkdownLink.ReplaceAllString(line, "$1 ($2)") + line = strings.ReplaceAll(line, "**", "") + line = strings.ReplaceAll(line, "__", "") + out = append(out, line) + } + + return strings.TrimRight(strings.Join(out, "\n"), "\n") + "\n" +} diff --git a/internal/cms/menu.go b/internal/cms/menu.go new file mode 100644 index 0000000..f430525 --- /dev/null +++ b/internal/cms/menu.go @@ -0,0 +1,193 @@ +package cms + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +func CreateMenu(root, name string) (string, error) { + name = slugify(name) + if name == "" || name == "untitled" { + return "", fmt.Errorf("menu name is required") + } + path := filepath.Join(root, "site/menus", name+".toml") + data := "name = " + formatString(name) + "\n\n" + return path, writeNewFile(path, []byte(data)) +} + +func LoadMenus(root string) (map[string]Menu, error) { + dir := filepath.Join(root, "site/menus") + menus := map[string]Menu{} + if _, err := os.Stat(dir); err != nil { + if os.IsNotExist(err) { + return menus, nil + } + return nil, err + } + err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || filepath.Ext(path) != ".toml" { + return nil + } + menu, err := loadMenu(path) + if err != nil { + return err + } + menus[menu.Name] = menu + return nil + }) + return menus, err +} + +func AddMenuItem(root string, opts MenuItemOptions) error { + name := slugify(opts.Menu) + if name == "" || name == "untitled" { + return fmt.Errorf("menu name is required") + } + path := filepath.Join(root, "site/menus", name+".toml") + menu, err := loadMenu(path) + if err != nil { + if os.IsNotExist(err) { + if _, createErr := CreateMenu(root, name); createErr != nil { + return createErr + } + menu = Menu{Name: name} + } else { + return err + } + } + item, err := resolveMenuItem(opts) + if err != nil { + return err + } + menu.Items = append(menu.Items, item) + return saveMenu(path, menu) +} + +func loadMenu(path string) (Menu, error) { + data, err := os.ReadFile(path) + if err != nil { + return Menu{}, err + } + menu := Menu{Name: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))} + var current *MenuItem + + for lineNo, raw := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if line == "[[items]]" { + menu.Items = append(menu.Items, MenuItem{}) + current = &menu.Items[len(menu.Items)-1] + continue + } + key, rawValue, ok := strings.Cut(line, "=") + if !ok { + return Menu{}, fmt.Errorf("%s:%d: expected key = value", path, lineNo+1) + } + value, err := parseScalar(strings.TrimSpace(rawValue)) + if err != nil { + return Menu{}, fmt.Errorf("%s:%d: %w", path, lineNo+1, err) + } + key = strings.TrimSpace(key) + if current == nil { + if key == "name" { + menu.Name = value + } + continue + } + switch key { + case "label": + current.Label = value + case "url": + current.URL = value + } + } + if menu.Name == "" { + menu.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + } + return menu, nil +} + +func saveMenu(path string, menu Menu) error { + var b strings.Builder + b.WriteString("name = " + formatString(menu.Name) + "\n") + for _, item := range menu.Items { + b.WriteString("\n[[items]]\n") + b.WriteString("label = " + formatString(item.Label) + "\n") + b.WriteString("url = " + formatString(item.URL) + "\n") + } + return writeFile(path, []byte(b.String())) +} + +func resolveMenuItem(opts MenuItemOptions) (MenuItem, error) { + label := opts.Label + url := opts.URL + target := slugify(opts.Target) + + switch opts.Kind { + case "home": + if label == "" { + label = "Home" + } + if url == "" { + url = "/" + } + case "page": + if target == "" || target == "untitled" { + return MenuItem{}, fmt.Errorf("page menu item requires a target") + } + if label == "" { + label = titleize(target) + } + if url == "" { + if target == "index" { + url = "/" + } else { + url = "/" + target + ".gmi" + } + } + case "section": + if target == "" || target == "untitled" { + return MenuItem{}, fmt.Errorf("section menu item requires a target") + } + if label == "" { + label = titleize(target) + } + if url == "" { + url = "/" + target + "/" + } + case "article": + if target == "" || target == "untitled" { + return MenuItem{}, fmt.Errorf("article menu item requires a target") + } + if label == "" { + label = titleize(target) + } + if url == "" { + url = "/articles/" + target + ".gmi" + } + case "custom": + if label == "" { + label = titleize(target) + } + if url == "" { + return MenuItem{}, fmt.Errorf("custom menu item requires --url") + } + default: + if label == "" { + label = titleize(opts.Kind) + } + if url == "" { + url = "/" + slugify(opts.Kind) + ".gmi" + } + } + + return MenuItem{Label: label, URL: url}, nil +} diff --git a/internal/cms/project.go b/internal/cms/project.go new file mode 100644 index 0000000..4dcad93 --- /dev/null +++ b/internal/cms/project.go @@ -0,0 +1,407 @@ +package cms + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +func FindRoot(start string) (string, error) { + abs, err := filepath.Abs(start) + if err != nil { + return "", err + } + info, err := os.Stat(abs) + if err != nil { + return "", err + } + if !info.IsDir() { + abs = filepath.Dir(abs) + } + + for { + if _, err := os.Stat(filepath.Join(abs, "capsule.toml")); err == nil { + return abs, nil + } + parent := filepath.Dir(abs) + if parent == abs { + return "", errors.New("not inside a gemcms capsule; run gemcms create site first") + } + abs = parent + } +} + +func CreateSite(parent string, opts SiteOptions) (string, error) { + name := slugify(opts.Name) + if name == "" || name == "untitled" { + return "", errors.New("site name is required") + } + root, err := filepath.Abs(filepath.Join(parent, name)) + if err != nil { + return "", err + } + if _, err := os.Stat(root); err == nil { + return "", fmt.Errorf("%s already exists", root) + } + + title := opts.Title + if title == "" { + title = titleize(name) + } + language := opts.Language + if language == "" { + language = "en" + } + cfg := Config{ + Title: title, + Host: opts.Host, + Author: opts.Author, + Language: language, + Theme: "default", + } + + dirs := []string{ + "content/pages", + "content/articles", + "assets", + "site/menus", + "site/sections", + "site/widgets", + "public", + } + for _, dir := range dirs { + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { + return "", err + } + } + + if err := writeNewFile(filepath.Join(root, "capsule.toml"), []byte(renderConfig(cfg))); err != nil { + return "", err + } + if _, err := CreateMenu(root, "top"); err != nil { + return "", err + } + if err := AddMenuItem(root, MenuItemOptions{Menu: "top", Kind: "home"}); err != nil { + return "", err + } + if _, err := CreateWidget(root, WidgetOptions{Name: "recent-articles", Type: "recent", Section: "articles", Limit: 5}); err != nil { + return "", err + } + + index := Document{ + Type: "page", + Slug: "index", + Title: title, + Body: strings.TrimSpace(`# `+title+` + +{{ menu top }} + +## Latest articles + +{{ widget recent-articles }} +`) + "\n", + } + if err := writeDocument(filepath.Join(root, "content/pages/index.gmi"), index); err != nil { + return "", err + } + + return root, nil +} + +func CreatePage(root string, opts PageOptions) (Document, error) { + slug := slugify(opts.Slug) + if slug == "" || slug == "untitled" { + return Document{}, errors.New("page slug is required") + } + title := opts.Title + if title == "" { + title = titleize(slug) + } + doc := Document{ + Type: "page", + Slug: slug, + Title: title, + Body: strings.TrimSpace(`{{ menu top }} + +# `+title+` + +Write this page. +`) + "\n", + } + path := filepath.Join(root, "content/pages", slug+".gmi") + if err := writeDocument(path, doc); err != nil { + return Document{}, err + } + doc.SourcePath = path + if opts.Menu != "" { + if err := AddMenuItem(root, MenuItemOptions{Menu: opts.Menu, Kind: "page", Target: slug, Label: title}); err != nil { + return Document{}, err + } + } + return doc, nil +} + +func CreateArticle(root string, opts ArticleOptions) (Document, error) { + title := strings.TrimSpace(opts.Title) + if title == "" { + return Document{}, errors.New("article title is required") + } + section := slugify(opts.Section) + if section == "" || section == "untitled" { + section = "articles" + } + slug := slugify(title) + date := time.Now().Format("2006-01-02") + doc := Document{ + Type: "article", + Slug: slug, + Title: title, + Date: date, + Section: section, + Tags: opts.Tags, + Draft: opts.Draft, + Body: strings.TrimSpace(`{{ menu top }} + +# `+title+` + +Write this article. +`) + "\n", + } + + path := filepath.Join(root, "content/articles", date+"-"+slug+".gmi") + path = nextAvailablePath(path) + if err := writeDocument(path, doc); err != nil { + return Document{}, err + } + doc.SourcePath = path + if opts.Menu != "" && !doc.Draft { + if err := AddMenuItem(root, MenuItemOptions{Menu: opts.Menu, Kind: "custom", Label: title, URL: docURL(doc)}); err != nil { + return Document{}, err + } + } + return doc, nil +} + +func AddFile(root string, opts AddOptions) (Document, error) { + sourcePath, err := expandPath(opts.Path) + if err != nil { + return Document{}, err + } + data, err := os.ReadFile(sourcePath) + if err != nil { + return Document{}, err + } + + meta, body, hasMeta := splitFrontmatter(string(data)) + doc := Document{ + Type: opts.Type, + Body: strings.TrimLeft(body, "\n"), + } + if hasMeta { + table, err := parseMetadata(meta) + if err != nil { + return Document{}, fmt.Errorf("%s: parse frontmatter: %w", sourcePath, err) + } + if doc.Type == "" { + doc.Type = table.values["type"] + } + doc.Title = table.values["title"] + doc.Slug = table.values["slug"] + doc.Date = table.values["date"] + doc.Section = table.values["section"] + doc.Tags = table.lists["tags"] + doc.Draft = table.values["draft"] == "true" + } + + ext := strings.ToLower(filepath.Ext(sourcePath)) + switch ext { + case ".md", ".markdown": + doc.Body = markdownToGemtext(doc.Body) + case ".gmi", ".gemini": + default: + return Document{}, fmt.Errorf("unsupported input extension %q; use .md or .gmi", ext) + } + + if opts.Type != "" { + doc.Type = opts.Type + } + if doc.Type == "" { + doc.Type = "article" + } + if doc.Type != "article" && doc.Type != "page" { + return Document{}, fmt.Errorf("unsupported content type %q", doc.Type) + } + if opts.Title != "" { + doc.Title = opts.Title + } + if doc.Title == "" { + doc.Title = inferTitle(doc.Body, inferSlug(sourcePath)) + } + if opts.Slug != "" { + doc.Slug = slugify(opts.Slug) + } + if doc.Slug == "" { + doc.Slug = slugify(doc.Title) + } + if len(opts.Tags) > 0 { + doc.Tags = opts.Tags + } + if opts.Draft { + doc.Draft = true + } + + var dest string + if doc.Type == "page" { + dest = filepath.Join(root, "content/pages", doc.Slug+".gmi") + dest = nextAvailablePath(dest) + } else { + if opts.Section != "" { + doc.Section = slugify(opts.Section) + } + if doc.Section == "" || doc.Section == "untitled" { + doc.Section = "articles" + } + if doc.Date == "" { + doc.Date = time.Now().Format("2006-01-02") + } + dest = filepath.Join(root, "content/articles", doc.Date+"-"+doc.Slug+".gmi") + dest = nextAvailablePath(dest) + } + if err := writeDocument(dest, doc); err != nil { + return Document{}, err + } + doc.SourcePath = dest + if opts.Menu != "" && !doc.Draft { + if err := AddMenuItem(root, MenuItemOptions{Menu: opts.Menu, Kind: "custom", Label: doc.Title, URL: docURL(doc)}); err != nil { + return Document{}, err + } + } + return doc, nil +} + +func CreateSection(root string, opts SectionOptions) (string, error) { + name := slugify(opts.Name) + if name == "" || name == "untitled" { + return "", errors.New("section name is required") + } + title := opts.Title + if title == "" { + title = titleize(name) + } + path := filepath.Join(root, "site/sections", name+".toml") + data := "name = " + formatString(name) + "\n" + + "title = " + formatString(title) + "\n" + return path, writeNewFile(path, []byte(data)) +} + +func LoadProject(root string) (Project, error) { + cfg, err := LoadConfig(root) + if err != nil { + return Project{}, err + } + docs, err := LoadDocuments(root) + if err != nil { + return Project{}, err + } + menus, err := LoadMenus(root) + if err != nil { + return Project{}, err + } + sections, err := LoadSections(root) + if err != nil { + return Project{}, err + } + widgets, err := LoadWidgets(root) + if err != nil { + return Project{}, err + } + return Project{ + Root: root, + Config: cfg, + Docs: docs, + Menus: menus, + Sections: sections, + Widgets: widgets, + }, nil +} + +func List(root, kind string) ([]string, error) { + project, err := LoadProject(root) + if err != nil { + return nil, err + } + var items []string + switch kind { + case "pages": + for _, doc := range project.Docs { + if doc.Type == "page" { + items = append(items, doc.Slug+"\t"+doc.Title) + } + } + case "articles": + for _, doc := range project.Docs { + if doc.Type == "article" { + items = append(items, doc.Date+"\t"+doc.Section+"\t"+doc.Slug+"\t"+doc.Title) + } + } + case "menus": + for name, menu := range project.Menus { + items = append(items, fmt.Sprintf("%s\t%d items", name, len(menu.Items))) + } + case "widgets": + for name, widget := range project.Widgets { + items = append(items, fmt.Sprintf("%s\t%s", name, widget.Type)) + } + default: + return nil, fmt.Errorf("unknown list target %q", kind) + } + sort.Strings(items) + return items, nil +} + +func writeNewFile(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("%s already exists", path) + } + return os.WriteFile(path, data, 0o644) +} + +func writeFile(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func nextAvailablePath(path string) string { + if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { + return path + } + ext := filepath.Ext(path) + base := strings.TrimSuffix(path, ext) + for i := 2; ; i++ { + candidate := fmt.Sprintf("%s-%d%s", base, i, ext) + if _, err := os.Stat(candidate); errors.Is(err, fs.ErrNotExist) { + return candidate + } + } +} + +func expandPath(pathName string) (string, error) { + if strings.HasPrefix(pathName, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + pathName = filepath.Join(home, strings.TrimPrefix(pathName, "~/")) + } + return filepath.Abs(pathName) +} diff --git a/internal/cms/section.go b/internal/cms/section.go new file mode 100644 index 0000000..fbb9013 --- /dev/null +++ b/internal/cms/section.go @@ -0,0 +1,54 @@ +package cms + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" +) + +func LoadSections(root string) (map[string]Section, error) { + dir := filepath.Join(root, "site/sections") + sections := map[string]Section{} + if _, err := os.Stat(dir); err != nil { + if os.IsNotExist(err) { + return sections, nil + } + return nil, err + } + err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || filepath.Ext(path) != ".toml" { + return nil + } + section, err := loadSection(path) + if err != nil { + return err + } + sections[section.Name] = section + return nil + }) + return sections, err +} + +func loadSection(path string) (Section, error) { + data, err := os.ReadFile(path) + if err != nil { + return Section{}, err + } + table, err := parseSimpleTable(string(data)) + if err != nil { + return Section{}, fmt.Errorf("%s: %w", path, err) + } + name := table.values["name"] + if name == "" { + name = slugify(filepath.Base(path)) + } + title := table.values["title"] + if title == "" { + title = titleize(name) + } + return Section{Name: name, Title: title}, nil +} diff --git a/internal/cms/simpletoml.go b/internal/cms/simpletoml.go new file mode 100644 index 0000000..af2dcc7 --- /dev/null +++ b/internal/cms/simpletoml.go @@ -0,0 +1,172 @@ +package cms + +import ( + "fmt" + "strconv" + "strings" +) + +type simpleTable struct { + values map[string]string + lists map[string][]string +} + +func parseSimpleTable(data string) (simpleTable, error) { + table := simpleTable{ + values: map[string]string{}, + lists: map[string][]string{}, + } + + for lineNo, raw := range strings.Split(data, "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "[[") { + continue + } + key, rawValue, ok := strings.Cut(line, "=") + if !ok { + return table, fmt.Errorf("line %d: expected key = value", lineNo+1) + } + key = strings.TrimSpace(key) + rawValue = strings.TrimSpace(rawValue) + if key == "" { + return table, fmt.Errorf("line %d: empty key", lineNo+1) + } + if strings.HasPrefix(rawValue, "[") { + list, err := parseStringList(rawValue) + if err != nil { + return table, fmt.Errorf("line %d: %w", lineNo+1, err) + } + table.lists[key] = list + continue + } + value, err := parseScalar(rawValue) + if err != nil { + return table, fmt.Errorf("line %d: %w", lineNo+1, err) + } + table.values[key] = value + } + + return table, nil +} + +func parseMetadata(data string) (simpleTable, error) { + table, err := parseSimpleTable(data) + if err == nil { + return table, nil + } + yamlTable, yamlErr := parseSimpleYAMLTable(data) + if yamlErr == nil { + return yamlTable, nil + } + return table, err +} + +func parseSimpleYAMLTable(data string) (simpleTable, error) { + table := simpleTable{ + values: map[string]string{}, + lists: map[string][]string{}, + } + + for lineNo, raw := range strings.Split(data, "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, rawValue, ok := strings.Cut(line, ":") + if !ok { + return table, fmt.Errorf("line %d: expected key: value", lineNo+1) + } + key = strings.TrimSpace(key) + rawValue = strings.TrimSpace(rawValue) + if key == "" { + return table, fmt.Errorf("line %d: empty key", lineNo+1) + } + if strings.HasPrefix(rawValue, "[") { + list, err := parseStringList(rawValue) + if err != nil { + return table, fmt.Errorf("line %d: %w", lineNo+1, err) + } + table.lists[key] = list + continue + } + if key == "tags" && strings.Contains(rawValue, ",") { + table.lists[key] = splitCommaList(rawValue) + continue + } + value, err := parseScalar(rawValue) + if err != nil { + return table, fmt.Errorf("line %d: %w", lineNo+1, err) + } + table.values[key] = value + } + + return table, nil +} + +func parseScalar(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + if strings.HasPrefix(raw, "\"") { + value, err := strconv.Unquote(raw) + if err != nil { + return "", err + } + return value, nil + } + return raw, nil +} + +func splitCommaList(value string) []string { + var out []string + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func parseStringList(raw string) ([]string, error) { + raw = strings.TrimSpace(raw) + if !strings.HasPrefix(raw, "[") || !strings.HasSuffix(raw, "]") { + return nil, fmt.Errorf("expected string list") + } + raw = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(raw, "["), "]")) + if raw == "" { + return nil, nil + } + + var values []string + for _, part := range strings.Split(raw, ",") { + value, err := parseScalar(strings.TrimSpace(part)) + if err != nil { + return nil, err + } + if value != "" { + values = append(values, value) + } + } + return values, nil +} + +func formatString(value string) string { + return strconv.Quote(value) +} + +func formatStringList(values []string) string { + quoted := make([]string, 0, len(values)) + for _, value := range values { + quoted = append(quoted, strconv.Quote(value)) + } + return "[" + strings.Join(quoted, ", ") + "]" +} + +func boolString(value bool) string { + if value { + return "true" + } + return "false" +} diff --git a/internal/cms/slug.go b/internal/cms/slug.go new file mode 100644 index 0000000..ecbaf7d --- /dev/null +++ b/internal/cms/slug.go @@ -0,0 +1,51 @@ +package cms + +import ( + "strings" + "unicode" +) + +func slugify(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + var b strings.Builder + lastDash := false + + for _, r := range value { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + lastDash = false + case r >= '0' && r <= '9': + b.WriteRune(r) + lastDash = false + case unicode.IsSpace(r) || r == '-' || r == '_' || r == '.': + if !lastDash && b.Len() > 0 { + b.WriteByte('-') + lastDash = true + } + } + } + + out := strings.Trim(b.String(), "-") + if out == "" { + return "untitled" + } + return out +} + +func titleize(value string) string { + value = strings.ReplaceAll(value, "-", " ") + value = strings.TrimSpace(value) + if value == "" { + return "Untitled" + } + + parts := strings.Fields(value) + for i, part := range parts { + if part == "" { + continue + } + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + return strings.Join(parts, " ") +} diff --git a/internal/cms/status.go b/internal/cms/status.go new file mode 100644 index 0000000..8147ee7 --- /dev/null +++ b/internal/cms/status.go @@ -0,0 +1,50 @@ +package cms + +import ( + "os" + "path/filepath" +) + +func Status(root string) (StatusReport, error) { + project, err := LoadProject(root) + if err != nil { + return StatusReport{}, err + } + check, err := Check(root) + if err != nil { + return StatusReport{}, err + } + report := StatusReport{ + Root: root, + Config: project.Config, + Menus: len(project.Menus), + Sections: len(project.Sections), + Widgets: len(project.Widgets), + PublicFiles: countPublicFiles(filepath.Join(root, "public")), + Issues: check.Issues, + } + for _, doc := range project.Docs { + switch doc.Type { + case "page": + report.Pages++ + case "article": + report.Articles++ + } + if doc.Draft { + report.Drafts++ + } + } + return report, nil +} + +func countPublicFiles(root string) int { + count := 0 + filepath.WalkDir(root, func(_ string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return nil + } + count++ + return nil + }) + return count +} diff --git a/internal/cms/types.go b/internal/cms/types.go new file mode 100644 index 0000000..3d72f7b --- /dev/null +++ b/internal/cms/types.go @@ -0,0 +1,174 @@ +package cms + +type Config struct { + Title string + Host string + Author string + Language string + Theme string +} + +type SiteOptions struct { + Name string + Title string + Host string + Author string + Language string +} + +type PageOptions struct { + Slug string + Title string + Menu string +} + +type ArticleOptions struct { + Title string + Section string + Tags []string + Draft bool + Menu string +} + +type AddOptions struct { + Path string + Type string + Title string + Slug string + Section string + Tags []string + Draft bool + Menu string +} + +type AddReport struct { + Documents []Document +} + +type SectionOptions struct { + Name string + Title string +} + +type WidgetOptions struct { + Type string + Name string + Section string + Limit int +} + +type MenuItemOptions struct { + Menu string + Kind string + Target string + Label string + URL string +} + +type Document struct { + Type string + Slug string + Title string + Date string + Section string + Tags []string + Draft bool + Body string + SourcePath string +} + +type Menu struct { + Name string + Items []MenuItem +} + +type MenuItem struct { + Label string + URL string +} + +type Section struct { + Name string + Title string +} + +type Widget struct { + Name string + Type string + Section string + Limit int +} + +type Project struct { + Root string + Config Config + Docs []Document + Menus map[string]Menu + Sections map[string]Section + Widgets map[string]Widget +} + +type BuildReport struct { + OutputDir string + FilesWritten int +} + +type CheckReport struct { + Issues []Issue +} + +type StatusReport struct { + Root string + Config Config + Pages int + Articles int + Drafts int + Menus int + Sections int + Widgets int + PublicFiles int + Issues []Issue +} + +type Issue struct { + Level string + Path string + Message string +} + +func (r CheckReport) HasErrors() bool { + for _, issue := range r.Issues { + if issue.Level == "ERROR" { + return true + } + } + return false +} + +func (r StatusReport) ErrorCount() int { + return countIssues(r.Issues, "ERROR") +} + +func (r StatusReport) WarningCount() int { + return countIssues(r.Issues, "WARN") +} + +func (r StatusReport) Health() string { + if r.ErrorCount() > 0 { + return "ERROR" + } + if r.WarningCount() > 0 { + return "WARN" + } + return "OK" +} + +func countIssues(issues []Issue, level string) int { + count := 0 + for _, issue := range issues { + if issue.Level == level { + count++ + } + } + return count +} diff --git a/internal/cms/widget.go b/internal/cms/widget.go new file mode 100644 index 0000000..0b24075 --- /dev/null +++ b/internal/cms/widget.go @@ -0,0 +1,106 @@ +package cms + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" +) + +func CreateWidget(root string, opts WidgetOptions) (string, error) { + path, data, err := renderWidgetConfig(root, opts) + if err != nil { + return "", err + } + return path, writeNewFile(path, data) +} + +func UpsertWidget(root string, opts WidgetOptions) (string, error) { + path, data, err := renderWidgetConfig(root, opts) + if err != nil { + return "", err + } + return path, writeFile(path, data) +} + +func renderWidgetConfig(root string, opts WidgetOptions) (string, []byte, error) { + widgetType := slugify(opts.Type) + if widgetType == "" || widgetType == "untitled" { + return "", nil, fmt.Errorf("widget type is required") + } + name := slugify(opts.Name) + if name == "" || name == "untitled" { + name = widgetType + } + section := slugify(opts.Section) + if section == "" || section == "untitled" { + section = "articles" + } + limit := opts.Limit + if limit < 1 { + limit = 5 + } + path := filepath.Join(root, "site/widgets", name+".toml") + data := "name = " + formatString(name) + "\n" + + "type = " + formatString(widgetType) + "\n" + + "section = " + formatString(section) + "\n" + + "limit = " + strconv.Itoa(limit) + "\n" + return path, []byte(data), nil +} + +func LoadWidgets(root string) (map[string]Widget, error) { + dir := filepath.Join(root, "site/widgets") + widgets := map[string]Widget{} + if _, err := os.Stat(dir); err != nil { + if os.IsNotExist(err) { + return widgets, nil + } + return nil, err + } + err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || filepath.Ext(path) != ".toml" { + return nil + } + widget, err := loadWidget(path) + if err != nil { + return err + } + widgets[widget.Name] = widget + return nil + }) + return widgets, err +} + +func loadWidget(path string) (Widget, error) { + data, err := os.ReadFile(path) + if err != nil { + return Widget{}, err + } + table, err := parseSimpleTable(string(data)) + if err != nil { + return Widget{}, fmt.Errorf("%s: %w", path, err) + } + limit, _ := strconv.Atoi(table.values["limit"]) + if limit < 1 { + limit = 5 + } + name := table.values["name"] + if name == "" { + name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + } + section := table.values["section"] + if section == "" { + section = "articles" + } + return Widget{ + Name: name, + Type: table.values["type"], + Section: section, + Limit: limit, + }, nil +} |
