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