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) }