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/project.go | |
| download | gemcms-37f156c30e2a2b01c840d39f0dd2bdbf811732d6.tar.gz gemcms-37f156c30e2a2b01c840d39f0dd2bdbf811732d6.tar.xz gemcms-37f156c30e2a2b01c840d39f0dd2bdbf811732d6.zip | |
Diffstat (limited to 'internal/cms/project.go')
| -rw-r--r-- | internal/cms/project.go | 407 |
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) +} |
