summaryrefslogtreecommitdiffstats
path: root/internal/cms
diff options
context:
space:
mode:
Diffstat (limited to 'internal/cms')
-rw-r--r--internal/cms/build.go274
-rw-r--r--internal/cms/check.go162
-rw-r--r--internal/cms/cms_test.go186
-rw-r--r--internal/cms/config.go40
-rw-r--r--internal/cms/content.go166
-rw-r--r--internal/cms/import.go82
-rw-r--r--internal/cms/markdown.go43
-rw-r--r--internal/cms/menu.go193
-rw-r--r--internal/cms/project.go407
-rw-r--r--internal/cms/section.go54
-rw-r--r--internal/cms/simpletoml.go172
-rw-r--r--internal/cms/slug.go51
-rw-r--r--internal/cms/status.go50
-rw-r--r--internal/cms/types.go174
-rw-r--r--internal/cms/widget.go106
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
+}