summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md19
-rw-r--r--PROJECT_CONTEXT.md35
-rw-r--r--README.md96
-rw-r--r--TODO.md18
-rw-r--r--cmd/gemcms/main.go16
-rw-r--r--docs/decisions.md70
-rw-r--r--docs/help.md430
-rw-r--r--go.mod3
-rw-r--r--internal/cli/cli.go497
-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
24 files changed, 3344 insertions, 0 deletions
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..d27a576
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,19 @@
+# Project Instructions
+
+## Context
+
+- Before substantial work, read `PROJECT_CONTEXT.md`, `TODO.md`, and `docs/decisions.md`.
+- Keep project memory concise and update it when stable product or architecture decisions change.
+
+## Workflow
+
+- This is a file-based Gemini capsule CMS implemented as a Go CLI.
+- Keep the CLI usable without external services, databases, or network access.
+- Preserve Gemtext-first behavior; HTML output is optional future work, not core behavior.
+- Keep command parsing separate from CMS/build logic.
+
+## Checks
+
+- `gofmt -w .`
+- `go test ./...`
+- `go vet ./...`
diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md
new file mode 100644
index 0000000..603dfa7
--- /dev/null
+++ b/PROJECT_CONTEXT.md
@@ -0,0 +1,35 @@
+# Project Context
+
+## Overview
+
+`gemcms` is a command-line CMS for creating and maintaining Gemini capsules with minimal ceremony. It should make common capsule work easy: creating a whole capsule in one command, importing files or directories, creating pages, articles, menus, generated blocks, static builds, and validation.
+
+The target user is comfortable with a terminal and wants file-based content that remains editable, versionable, and deployable without a database.
+
+## Stack
+
+- Language: Go
+- Interface: CLI
+- Storage: files in the capsule project directory
+- Database: none
+- Deployment: static generated `public/` directory, publish command planned later
+
+## Architecture
+
+- `cmd/gemcms`: executable entrypoint.
+- `internal/cli`: argument parsing and command dispatch.
+- `internal/cms`: project creation, content import, content parsing, menu/widget config, build, status, and checks.
+
+The CMS core should be callable without the CLI so tests can cover behavior directly.
+
+## Constraints
+
+- Use a small TOML-like subset for `capsule.toml`, menus, widgets, and content frontmatter until a dependency is justified.
+- Do not store secrets in project files.
+- Generated output must be Gemini-native Gemtext.
+- Widgets are static build-time blocks, not runtime components.
+- Directory imports should accept Markdown/Gemtext files and ignore unrelated files.
+
+## Links
+
+- Project root: `/home/gabriel1/.codex/projects/gemcms`
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e39cbd6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,96 @@
+# gemcms
+
+`gemcms` is a small CLI CMS for Gemini capsules.
+
+It keeps capsule content as plain files, generates Gemtext into `public/`, and validates common mistakes before publishing.
+
+## Quick Start
+
+```bash
+go run ./cmd/gemcms create capsule my-capsule --title "My Capsule" --host gemini.example.org --import ~/articles --section journal --tags gemini --build
+cd my-capsule
+
+go run ../cmd/gemcms add ~/article.md --section journal --tags gemini --draft --menu top
+go run ../cmd/gemcms add ~/notes/ --section journal --tags gemini
+go run ../cmd/gemcms create article "Hello Gemini" --tags gemini,capsule
+go run ../cmd/gemcms create page about --title "About"
+go run ../cmd/gemcms menu add top page about --label "About"
+go run ../cmd/gemcms status
+go run ../cmd/gemcms health
+go run ../cmd/gemcms build
+go run ../cmd/gemcms check
+```
+
+Inside an installed build, replace `go run ../cmd/gemcms` with `gemcms`.
+
+## Commands
+
+```text
+gemcms add <file-or-directory> [--type article|page] [--section name] [--tags a,b] [--draft] [--menu name]
+gemcms create capsule <name> [--title title] [--host host] [--import path] [--section name] [--tags a,b] [--menu name] [--build]
+gemcms create site <name> [--title title] [--host host]
+gemcms create page <slug> [--title title]
+gemcms create article <title> [--section name] [--tags a,b] [--draft]
+gemcms create section <name> [--title title]
+gemcms create menu <name>
+gemcms create widget <type> [--name name] [--section name] [--limit n]
+gemcms menu add <menu> <home|page|section|article|custom> [target] [--label label] [--url url]
+gemcms list <pages|articles|menus|widgets>
+gemcms status
+gemcms health
+gemcms build
+gemcms check
+```
+
+See [docs/help.md](docs/help.md) for the full command and option reference.
+
+## Capsule Layout
+
+```text
+capsule.toml
+content/
+ pages/
+ articles/
+assets/
+site/
+ menus/
+ sections/
+ widgets/
+public/
+```
+
+Content files use Gemtext with optional frontmatter:
+
+```text
++++
+title = "Hello Gemini"
+type = "article"
+slug = "hello-gemini"
+date = "2026-07-07"
+tags = ["gemini", "capsule"]
++++
+
+# Hello Gemini
+
+Gemtext content goes here.
+```
+
+Imported Markdown can also use simple YAML frontmatter:
+
+```text
+---
+title: Hello Gemini
+tags: gemini, capsule
+---
+
+# Hello Gemini
+```
+
+If no `--title` is provided, `gemcms add` uses frontmatter `title` first, then the first `# Heading`, then the file name.
+
+Use build-time blocks in Gemtext:
+
+```text
+{{ menu top }}
+{{ widget recent-articles }}
+```
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..ae59dbc
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,18 @@
+# TODO
+
+## Active
+
+- [ ] Add `publish` with dry-run support for rsync/scp-style targets.
+- [ ] Add a real Gemini preview server with local TLS configuration.
+- [ ] Add Atom feed generation.
+- [ ] Add richer template/theme support without making simple capsules harder.
+
+## Blocked
+
+- [ ] Decide final project/module name before publishing binaries.
+
+## Done
+
+- [x] Create initial Go CLI architecture - 2026-07-07
+- [x] Add `gemcms add <file.md|file.gmi>` import workflow - 2026-07-07
+- [x] Add directory import, one-shot capsule creation, and status/health commands - 2026-07-07
diff --git a/cmd/gemcms/main.go b/cmd/gemcms/main.go
new file mode 100644
index 0000000..5c0c67b
--- /dev/null
+++ b/cmd/gemcms/main.go
@@ -0,0 +1,16 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "gemcms/internal/cli"
+)
+
+func main() {
+ if err := cli.Run(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil {
+ fmt.Fprintln(os.Stderr, "gemcms:", err)
+ os.Exit(1)
+ }
+}
diff --git a/docs/decisions.md b/docs/decisions.md
new file mode 100644
index 0000000..54a759b
--- /dev/null
+++ b/docs/decisions.md
@@ -0,0 +1,70 @@
+# Decisions
+
+## 2026-07-07 - Build A Go CLI First
+
+Decision:
+
+Implement `gemcms` first as a Go command-line tool.
+
+Rationale:
+
+- A single static binary fits the self-hosted Gemini audience.
+- The core workflows are filesystem and text processing tasks.
+- Avoiding a web admin panel keeps the first version simple and robust.
+
+Consequences:
+
+- The first user experience is terminal-first.
+- A TUI or local web editor can be added later without changing the capsule format.
+
+## 2026-07-07 - Keep Capsules File-Based
+
+Decision:
+
+Store pages, articles, menus, widgets, and config as project files. Do not use a database.
+
+Rationale:
+
+- Gemini capsules are naturally static and text-first.
+- File storage keeps content easy to edit, back up, diff, and publish.
+- A database would add operational cost before it solves a real problem.
+
+Consequences:
+
+- Build-time validation and generation are important.
+- Future features should preserve direct file editability.
+
+## 2026-07-07 - Treat Widgets As Build-Time Blocks
+
+Decision:
+
+Widgets are static Gemtext blocks expanded during `gemcms build`.
+
+Rationale:
+
+- Gemini has no browser-side runtime.
+- Static blocks keep output simple and portable.
+- Menus, recent articles, indexes, and similar blocks can be generated reliably.
+
+Consequences:
+
+- Widget behavior is deterministic at build time.
+- Interactive runtime widgets are out of scope for the core CMS.
+
+## 2026-07-07 - Import Existing Content As A First-Class Workflow
+
+Decision:
+
+Support importing one file or a directory of Markdown/Gemtext files through `gemcms add`, and support `gemcms create capsule` for creating a capsule, importing content, and optionally building output in one command.
+
+Rationale:
+
+- Many users will already have notes or articles as `.md` or `.gmi` files.
+- A CMS for Gemini should reduce setup friction rather than requiring manual folder work.
+- Keeping imports deterministic and file-based preserves direct editability.
+
+Consequences:
+
+- Import behavior must infer metadata from frontmatter, headings, and filenames.
+- Directory imports should skip unrelated files rather than fail on them.
+- Health/status commands become important for confidence after bulk imports.
diff --git a/docs/help.md b/docs/help.md
new file mode 100644
index 0000000..fd61e79
--- /dev/null
+++ b/docs/help.md
@@ -0,0 +1,430 @@
+# gemcms Help
+
+`gemcms` is a file-based CLI CMS for Gemini capsules. Most commands are run
+inside a capsule directory, or one of its subdirectories. A capsule is detected
+by the presence of `capsule.toml`.
+
+## Global Usage
+
+```text
+gemcms
+gemcms help
+gemcms -h
+gemcms --help
+```
+
+With no arguments, or with `help`, `-h`, or `--help`, `gemcms` prints the short
+usage summary.
+
+Command flags can be placed before or after positional arguments:
+
+```bash
+gemcms add ~/article.md --section journal
+gemcms add --section journal ~/article.md
+```
+
+## Commands Overview
+
+```text
+gemcms add <file-or-directory> [options]
+gemcms create capsule <name> [options]
+gemcms create site <name> [options]
+gemcms create page <slug> [options]
+gemcms create article <title> [options]
+gemcms create section <name> [options]
+gemcms create menu <name>
+gemcms create widget <type> [options]
+gemcms menu add <menu> <kind> [target] [options]
+gemcms list <pages|articles|menus|widgets>
+gemcms status
+gemcms health
+gemcms build
+gemcms check
+```
+
+`create capsule` and `create site` are aliases.
+
+## `gemcms add`
+
+Import an existing Markdown or Gemtext file, or a directory containing importable
+files.
+
+```text
+gemcms add <file-or-directory> [--type article|page] [--title title] [--slug slug] [--section name] [--tags a,b] [--draft] [--menu name]
+```
+
+Options:
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `--type article|page` | `article` | Imported content type. Frontmatter `type` is used when present and this option is omitted. |
+| `--title title` | inferred | Content title. Only valid when importing one file. |
+| `--slug slug` | inferred | Content slug. Only valid when importing one file. |
+| `--section name` | `articles` | Article section. Ignored for pages. |
+| `--tags a,b` | none | Comma-separated tags. |
+| `--draft` | `false` | Mark imported content as draft. Drafts are not built and are not added to menus. |
+| `--menu name` | none | Add imported non-draft content to the named menu. |
+
+Accepted input extensions:
+
+```text
+.md
+.markdown
+.gmi
+.gemini
+```
+
+Directory imports recursively import supported files, skip hidden directories,
+and sort files deterministically. `--title` and `--slug` cannot be used with
+directory imports.
+
+Title inference order:
+
+1. Frontmatter `title`
+2. First `# Heading`
+3. File name converted to title case
+
+Examples:
+
+```bash
+gemcms add ~/article.md --section journal --tags gemini --draft --menu top
+gemcms add ~/notes/ --section journal --tags gemini
+gemcms add ~/about.gmi --type page --slug about --menu top
+```
+
+## `gemcms create capsule`
+
+Create a new capsule project. This command also creates the default top menu,
+home page, recent-articles widget, content directories, asset directory, and
+`public/`.
+
+```text
+gemcms create capsule <name> [--title title] [--host host] [--author name] [--language code] [--import path] [--type article|page] [--section name] [--tags a,b] [--draft] [--menu name] [--build]
+```
+
+Options:
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `--title title` | title-cased name | Capsule title written to `capsule.toml`. |
+| `--host host` | empty | Gemini host name. |
+| `--author name` | empty | Default author metadata. |
+| `--language code` | `en` | Content language metadata. |
+| `--import path` | none | Import one file or a directory after capsule creation. |
+| `--type article|page` | inferred or `article` | Type for imported content. |
+| `--section name` | `articles` | Section for imported articles. |
+| `--tags a,b` | none | Tags for imported content. |
+| `--draft` | `false` | Mark imported content as draft. |
+| `--menu name` | none | Add imported non-draft content to a menu. |
+| `--build` | `false` | Build `public/` after creation and optional import. |
+
+Example:
+
+```bash
+gemcms create capsule my-capsule --title "My Capsule" --host gemini.example.org --import ~/articles --section journal --tags gemini --build
+```
+
+## `gemcms create site`
+
+Alias of `create capsule`.
+
+```text
+gemcms create site <name> [same options as create capsule]
+```
+
+## `gemcms create page`
+
+Create a new page in `content/pages/`.
+
+```text
+gemcms create page <slug> [--title title] [--menu name]
+```
+
+Options:
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `--title title` | title-cased slug | Page title. |
+| `--menu name` | none | Add the page to the named menu. |
+
+Example:
+
+```bash
+gemcms create page about --title "About" --menu top
+```
+
+## `gemcms create article`
+
+Create a new article in `content/articles/`.
+
+```text
+gemcms create article <title> [--section name] [--tags a,b] [--draft] [--menu name]
+```
+
+Options:
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `--section name` | `articles` | Article section. |
+| `--tags a,b` | none | Comma-separated tags. |
+| `--draft` | `false` | Mark article as draft. Drafts are not built and are not added to menus. |
+| `--menu name` | none | Add the non-draft article to the named menu. |
+
+Example:
+
+```bash
+gemcms create article "Hello Gemini" --section journal --tags gemini,capsule --menu top
+```
+
+## `gemcms create section`
+
+Create a section config in `site/sections/`.
+
+```text
+gemcms create section <name> [--title title]
+```
+
+Options:
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `--title title` | title-cased name | Section display title. |
+
+Example:
+
+```bash
+gemcms create section journal --title "Journal"
+```
+
+## `gemcms create menu`
+
+Create an empty menu config in `site/menus/`.
+
+```text
+gemcms create menu <name>
+```
+
+Example:
+
+```bash
+gemcms create menu footer
+```
+
+## `gemcms create widget`
+
+Create a build-time widget config in `site/widgets/`.
+
+```text
+gemcms create widget <type> [--name name] [--section name] [--limit n]
+```
+
+Options:
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `--name name` | widget type | Widget config name. |
+| `--section name` | `articles` | Section used by recent article widgets. |
+| `--limit n` | `5` | Maximum number of items. Values below `1` become `5`. |
+
+Currently supported widget type:
+
+| Type | Description |
+| --- | --- |
+| `recent` | Render recent non-draft articles from one section. |
+
+Example:
+
+```bash
+gemcms create widget recent --name recent-journal --section journal --limit 10
+```
+
+Use the widget in content:
+
+```text
+{{ widget recent-journal }}
+```
+
+## `gemcms menu add`
+
+Add an item to a menu. Missing menus are created automatically.
+
+```text
+gemcms menu add <menu> <home|page|section|article|custom> [target] [--label label] [--url url]
+```
+
+Kinds:
+
+| Kind | Target required | Default URL | Notes |
+| --- | --- | --- | --- |
+| `home` | no | `/` | Default label: `Home`. |
+| `page` | yes | `/<target>.gmi` | `index` maps to `/`. |
+| `section` | yes | `/<target>/` | Links to a generated section index. |
+| `article` | yes | `/articles/<target>.gmi` | Uses the default `articles` section path. |
+| `custom` | yes | none | Requires `--url`. |
+
+Options:
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `--label label` | inferred | Link label. |
+| `--url url` | inferred | Link URL. Required for `custom`. |
+
+Examples:
+
+```bash
+gemcms menu add top home
+gemcms menu add top page about --label "About"
+gemcms menu add top section journal --label "Journal"
+gemcms menu add top custom external --label "Project" --url "gemini://example.org/"
+```
+
+Use the menu in content:
+
+```text
+{{ menu top }}
+```
+
+## `gemcms list`
+
+List project objects.
+
+```text
+gemcms list <pages|articles|menus|widgets>
+```
+
+Output:
+
+| Target | Output format |
+| --- | --- |
+| `pages` | `slug<TAB>title` |
+| `articles` | `date<TAB>section<TAB>slug<TAB>title` |
+| `menus` | `name<TAB>N items` |
+| `widgets` | `name<TAB>type` |
+
+Examples:
+
+```bash
+gemcms list pages
+gemcms list articles
+gemcms list menus
+gemcms list widgets
+```
+
+## `gemcms status`
+
+Print a project summary.
+
+```text
+gemcms status
+```
+
+Includes:
+
+- capsule root
+- title
+- host
+- page, article, and draft counts
+- menu, section, and widget counts
+- number of generated public files
+- health summary
+
+## `gemcms health`
+
+Print only health status and issues.
+
+```text
+gemcms health
+```
+
+Returns an error exit when errors are present. Warnings are printed but do not
+fail the command.
+
+## `gemcms build`
+
+Generate Gemtext output into `public/`.
+
+```text
+gemcms build
+```
+
+Build behavior:
+
+- Deletes and recreates `public/`.
+- Copies files from `assets/` into `public/`.
+- Skips draft documents.
+- Writes pages as `/<slug>.gmi`, with `index` mapped to `/index.gmi`.
+- Writes articles as `/<section>/<slug>.gmi`.
+- Generates `/index.gmi` if no `index` page exists.
+- Generates section indexes as `/<section>/index.gmi`.
+- Expands build-time blocks:
+ - `{{ menu name }}`
+ - `{{ widget name }}`
+
+## `gemcms check`
+
+Validate common capsule problems.
+
+```text
+gemcms check
+```
+
+Checks include:
+
+- missing capsule title
+- missing content documents
+- missing index page warning
+- missing slugs
+- duplicate output paths
+- broken or suspicious local Gemtext links
+- empty menu labels or URLs
+- missing widget type
+- invalid recent widget section
+
+The command prints `OK` when no issues are found. It returns an error exit when
+errors are present.
+
+## Capsule Layout
+
+```text
+capsule.toml
+content/
+ pages/
+ articles/
+assets/
+site/
+ menus/
+ sections/
+ widgets/
+public/
+```
+
+## Content Frontmatter
+
+Gemtext files use TOML-like frontmatter:
+
+```text
++++
+title = "Hello Gemini"
+type = "article"
+slug = "hello-gemini"
+date = "2026-07-07"
+section = "articles"
+tags = ["gemini", "capsule"]
+draft = true
++++
+
+# Hello Gemini
+```
+
+Imported Markdown can also use simple YAML-style frontmatter:
+
+```text
+---
+title: Hello Gemini
+tags: gemini, capsule
+---
+
+# Hello Gemini
+```
+
+Markdown imports are converted to Gemtext before being stored.
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..345bd3a
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module gemcms
+
+go 1.26
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
new file mode 100644
index 0000000..4df575c
--- /dev/null
+++ b/internal/cli/cli.go
@@ -0,0 +1,497 @@
+package cli
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "strings"
+
+ "gemcms/internal/cms"
+)
+
+const usage = `gemcms is a CLI CMS for Gemini capsules.
+
+Usage:
+ gemcms add <file-or-directory> [--type article|page] [--section name] [--tags a,b] [--draft] [--menu name]
+ gemcms create capsule <name> [--title title] [--host host] [--import path] [--section name] [--tags a,b] [--menu name] [--build]
+ gemcms create site <name> [--title title] [--host host]
+ gemcms create page <slug> [--title title]
+ gemcms create article <title> [--section name] [--tags a,b] [--draft]
+ gemcms create section <name> [--title title]
+ gemcms create menu <name>
+ gemcms create widget <type> [--name name] [--section name] [--limit n]
+ gemcms menu add <menu> <home|page|section|article|custom> [target] [--label label] [--url url]
+ gemcms list <pages|articles|menus|widgets>
+ gemcms status
+ gemcms health
+ gemcms build
+ gemcms check
+`
+
+func Run(ctx context.Context, args []string, out, errOut io.Writer) error {
+ _ = ctx
+
+ if len(args) == 0 {
+ fmt.Fprint(out, usage)
+ return nil
+ }
+
+ switch args[0] {
+ case "help", "-h", "--help":
+ fmt.Fprint(out, usage)
+ return nil
+ case "add":
+ return runAdd(args[1:], out, errOut)
+ case "create":
+ return runCreate(args[1:], out, errOut)
+ case "menu":
+ return runMenu(args[1:], out, errOut)
+ case "list":
+ return runList(args[1:], out)
+ case "status":
+ return runStatus(args[1:], out, false)
+ case "health":
+ return runStatus(args[1:], out, true)
+ case "build":
+ root, err := cms.FindRoot(".")
+ if err != nil {
+ return err
+ }
+ report, err := cms.Build(root)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "built %d files into %s\n", report.FilesWritten, report.OutputDir)
+ return nil
+ case "check":
+ root, err := cms.FindRoot(".")
+ if err != nil {
+ return err
+ }
+ report, err := cms.Check(root)
+ if err != nil {
+ return err
+ }
+ for _, issue := range report.Issues {
+ fmt.Fprintf(out, "%s: %s: %s\n", issue.Level, issue.Path, issue.Message)
+ }
+ if report.HasErrors() {
+ return errors.New("check failed")
+ }
+ if len(report.Issues) == 0 {
+ fmt.Fprintln(out, "OK")
+ }
+ return nil
+ default:
+ return fmt.Errorf("unknown command %q\n\n%s", args[0], usage)
+ }
+}
+
+func runAdd(args []string, out, errOut io.Writer) error {
+ fs := newFlagSet("add", errOut)
+ docType := fs.String("type", "", "content type: article or page")
+ title := fs.String("title", "", "content title")
+ slug := fs.String("slug", "", "content slug")
+ section := fs.String("section", "articles", "article section")
+ tags := fs.String("tags", "", "comma-separated tags")
+ draft := fs.Bool("draft", false, "mark content as draft")
+ menu := fs.String("menu", "", "menu to add the content to")
+ positionals, err := parseInterspersed(fs, args)
+ if err != nil {
+ return err
+ }
+ if len(positionals) != 1 {
+ return errors.New("usage: gemcms add <file-or-directory> [--type article|page] [--section name] [--tags a,b] [--draft] [--menu name]")
+ }
+ root, err := cms.FindRoot(".")
+ if err != nil {
+ return err
+ }
+ report, err := cms.AddPath(root, cms.AddOptions{
+ Path: positionals[0],
+ Type: *docType,
+ Title: *title,
+ Slug: *slug,
+ Section: *section,
+ Tags: splitCSV(*tags),
+ Draft: *draft,
+ Menu: *menu,
+ })
+ if err != nil {
+ return err
+ }
+ for _, doc := range report.Documents {
+ fmt.Fprintf(out, "added %s %s\n", doc.Type, doc.SourcePath)
+ }
+ return nil
+}
+
+func runCreate(args []string, out, errOut io.Writer) error {
+ if len(args) == 0 {
+ return fmt.Errorf("missing create target\n\n%s", usage)
+ }
+
+ switch args[0] {
+ case "site", "capsule":
+ fs := newFlagSet("create site", errOut)
+ title := fs.String("title", "", "capsule title")
+ host := fs.String("host", "", "Gemini host")
+ author := fs.String("author", "", "default author")
+ language := fs.String("language", "en", "content language")
+ importPath := fs.String("import", "", "file or directory to import after creating the capsule")
+ importType := fs.String("type", "", "imported content type")
+ section := fs.String("section", "articles", "section for imported articles")
+ tags := fs.String("tags", "", "comma-separated tags for imported content")
+ draft := fs.Bool("draft", false, "mark imported content as draft")
+ menu := fs.String("menu", "", "menu to add imported content to")
+ build := fs.Bool("build", false, "build public output after creation")
+ positionals, err := parseInterspersed(fs, args[1:])
+ if err != nil {
+ return err
+ }
+ if len(positionals) != 1 {
+ return errors.New("usage: gemcms create site <name> [--title title] [--host host]")
+ }
+ root, err := cms.CreateSite(".", cms.SiteOptions{
+ Name: positionals[0],
+ Title: *title,
+ Host: *host,
+ Author: *author,
+ Language: *language,
+ })
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "created site %s\n", root)
+ if *importPath != "" {
+ if *section != "articles" {
+ if _, err := cms.CreateSection(root, cms.SectionOptions{Name: *section}); err != nil {
+ return err
+ }
+ if _, err := cms.UpsertWidget(root, cms.WidgetOptions{Name: "recent-articles", Type: "recent", Section: *section, Limit: 5}); err != nil {
+ return err
+ }
+ }
+ report, err := cms.AddPath(root, cms.AddOptions{
+ Path: *importPath,
+ Type: *importType,
+ Section: *section,
+ Tags: splitCSV(*tags),
+ Draft: *draft,
+ Menu: *menu,
+ })
+ if err != nil {
+ return err
+ }
+ for _, doc := range report.Documents {
+ fmt.Fprintf(out, "added %s %s\n", doc.Type, doc.SourcePath)
+ }
+ }
+ if *build {
+ report, err := cms.Build(root)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "built %d files into %s\n", report.FilesWritten, report.OutputDir)
+ }
+ return nil
+ case "page":
+ fs := newFlagSet("create page", errOut)
+ title := fs.String("title", "", "page title")
+ menu := fs.String("menu", "", "menu to add the page to")
+ positionals, err := parseInterspersed(fs, args[1:])
+ if err != nil {
+ return err
+ }
+ if len(positionals) != 1 {
+ return errors.New("usage: gemcms create page <slug> [--title title]")
+ }
+ root, err := cms.FindRoot(".")
+ if err != nil {
+ return err
+ }
+ doc, err := cms.CreatePage(root, cms.PageOptions{
+ Slug: positionals[0],
+ Title: *title,
+ Menu: *menu,
+ })
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "created page %s\n", doc.SourcePath)
+ return nil
+ case "article":
+ fs := newFlagSet("create article", errOut)
+ section := fs.String("section", "articles", "article section")
+ tags := fs.String("tags", "", "comma-separated tags")
+ draft := fs.Bool("draft", false, "mark article as draft")
+ menu := fs.String("menu", "", "menu to add the article to")
+ positionals, err := parseInterspersed(fs, args[1:])
+ if err != nil {
+ return err
+ }
+ if len(positionals) < 1 {
+ return errors.New("usage: gemcms create article <title> [--section name] [--tags a,b] [--draft]")
+ }
+ root, err := cms.FindRoot(".")
+ if err != nil {
+ return err
+ }
+ doc, err := cms.CreateArticle(root, cms.ArticleOptions{
+ Title: strings.Join(positionals, " "),
+ Section: *section,
+ Tags: splitCSV(*tags),
+ Draft: *draft,
+ Menu: *menu,
+ })
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "created article %s\n", doc.SourcePath)
+ return nil
+ case "section":
+ fs := newFlagSet("create section", errOut)
+ title := fs.String("title", "", "section title")
+ positionals, err := parseInterspersed(fs, args[1:])
+ if err != nil {
+ return err
+ }
+ if len(positionals) != 1 {
+ return errors.New("usage: gemcms create section <name> [--title title]")
+ }
+ root, err := cms.FindRoot(".")
+ if err != nil {
+ return err
+ }
+ path, err := cms.CreateSection(root, cms.SectionOptions{Name: positionals[0], Title: *title})
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "created section %s\n", path)
+ return nil
+ case "menu":
+ fs := newFlagSet("create menu", errOut)
+ positionals, err := parseInterspersed(fs, args[1:])
+ if err != nil {
+ return err
+ }
+ if len(positionals) != 1 {
+ return errors.New("usage: gemcms create menu <name>")
+ }
+ root, err := cms.FindRoot(".")
+ if err != nil {
+ return err
+ }
+ path, err := cms.CreateMenu(root, positionals[0])
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "created menu %s\n", path)
+ return nil
+ case "widget":
+ fs := newFlagSet("create widget", errOut)
+ name := fs.String("name", "", "widget config name")
+ section := fs.String("section", "articles", "section for recent widgets")
+ limit := fs.Int("limit", 5, "item limit")
+ positionals, err := parseInterspersed(fs, args[1:])
+ if err != nil {
+ return err
+ }
+ if len(positionals) != 1 {
+ return errors.New("usage: gemcms create widget <type> [--name name] [--section name] [--limit n]")
+ }
+ root, err := cms.FindRoot(".")
+ if err != nil {
+ return err
+ }
+ path, err := cms.CreateWidget(root, cms.WidgetOptions{
+ Type: positionals[0],
+ Name: *name,
+ Section: *section,
+ Limit: *limit,
+ })
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "created widget %s\n", path)
+ return nil
+ default:
+ return fmt.Errorf("unknown create target %q", args[0])
+ }
+}
+
+func runMenu(args []string, out, errOut io.Writer) error {
+ if len(args) == 0 || args[0] != "add" {
+ return errors.New("usage: gemcms menu add <menu> <home|page|section|article|custom> [target] [--label label] [--url url]")
+ }
+ if len(args) < 3 {
+ return errors.New("usage: gemcms menu add <menu> <home|page|section|article|custom> [target] [--label label] [--url url]")
+ }
+
+ menuName := args[1]
+ kind := args[2]
+ target := ""
+ flagStart := 3
+ if requiresTarget(kind) {
+ if len(args) < 4 || strings.HasPrefix(args[3], "-") {
+ return fmt.Errorf("menu item %q requires a target", kind)
+ }
+ target = args[3]
+ flagStart = 4
+ }
+
+ fs := newFlagSet("menu add", errOut)
+ label := fs.String("label", "", "menu item label")
+ url := fs.String("url", "", "menu item URL")
+ positionals, err := parseInterspersed(fs, args[flagStart:])
+ if err != nil {
+ return err
+ }
+ if len(positionals) != 0 {
+ return fmt.Errorf("unexpected argument %q", positionals[0])
+ }
+
+ root, err := cms.FindRoot(".")
+ if err != nil {
+ return err
+ }
+ if err := cms.AddMenuItem(root, cms.MenuItemOptions{
+ Menu: menuName,
+ Kind: kind,
+ Target: target,
+ Label: *label,
+ URL: *url,
+ }); err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "added item to menu %s\n", menuName)
+ return nil
+}
+
+func runList(args []string, out io.Writer) error {
+ if len(args) != 1 {
+ return errors.New("usage: gemcms list <pages|articles|menus|widgets>")
+ }
+ root, err := cms.FindRoot(".")
+ if err != nil {
+ return err
+ }
+ items, err := cms.List(root, args[0])
+ if err != nil {
+ return err
+ }
+ for _, item := range items {
+ fmt.Fprintln(out, item)
+ }
+ return nil
+}
+
+func runStatus(args []string, out io.Writer, healthOnly bool) error {
+ if len(args) != 0 {
+ if healthOnly {
+ return errors.New("usage: gemcms health")
+ }
+ return errors.New("usage: gemcms status")
+ }
+ root, err := cms.FindRoot(".")
+ if err != nil {
+ return err
+ }
+ report, err := cms.Status(root)
+ if err != nil {
+ return err
+ }
+ if healthOnly {
+ fmt.Fprintf(out, "%s (%d errors, %d warnings)\n", report.Health(), report.ErrorCount(), report.WarningCount())
+ for _, issue := range report.Issues {
+ fmt.Fprintf(out, "%s: %s: %s\n", issue.Level, issue.Path, issue.Message)
+ }
+ if report.ErrorCount() > 0 {
+ return errors.New("health check failed")
+ }
+ return nil
+ }
+
+ fmt.Fprintf(out, "root: %s\n", report.Root)
+ fmt.Fprintf(out, "title: %s\n", report.Config.Title)
+ fmt.Fprintf(out, "host: %s\n", report.Config.Host)
+ fmt.Fprintf(out, "content: %d pages, %d articles, %d drafts\n", report.Pages, report.Articles, report.Drafts)
+ fmt.Fprintf(out, "site: %d menus, %d sections, %d widgets\n", report.Menus, report.Sections, report.Widgets)
+ fmt.Fprintf(out, "public: %d files\n", report.PublicFiles)
+ fmt.Fprintf(out, "health: %s (%d errors, %d warnings)\n", report.Health(), report.ErrorCount(), report.WarningCount())
+ return nil
+}
+
+func newFlagSet(name string, errOut io.Writer) *flag.FlagSet {
+ fs := flag.NewFlagSet(name, flag.ContinueOnError)
+ fs.SetOutput(errOut)
+ return fs
+}
+
+func parseInterspersed(fs *flag.FlagSet, args []string) ([]string, error) {
+ var flagArgs []string
+ var positionals []string
+
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ if !strings.HasPrefix(arg, "-") || arg == "-" {
+ positionals = append(positionals, arg)
+ continue
+ }
+ flagArgs = append(flagArgs, arg)
+ name := strings.TrimLeft(arg, "-")
+ if idx := strings.Index(name, "="); idx >= 0 {
+ name = name[:idx]
+ }
+ f := fs.Lookup(name)
+ if f == nil {
+ return nil, fmt.Errorf("unknown flag %s", arg)
+ }
+ if isBoolFlag(f) || strings.Contains(arg, "=") {
+ continue
+ }
+ if i+1 >= len(args) {
+ return nil, fmt.Errorf("flag %s requires a value", arg)
+ }
+ i++
+ flagArgs = append(flagArgs, args[i])
+ }
+
+ if err := fs.Parse(flagArgs); err != nil {
+ return nil, err
+ }
+ return positionals, nil
+}
+
+func isBoolFlag(f *flag.Flag) bool {
+ type boolFlag interface {
+ IsBoolFlag() bool
+ }
+ value, ok := f.Value.(boolFlag)
+ return ok && value.IsBoolFlag()
+}
+
+func splitCSV(value string) []string {
+ if value == "" {
+ return nil
+ }
+ var out []string
+ for _, part := range strings.Split(value, ",") {
+ part = strings.TrimSpace(part)
+ if part != "" {
+ out = append(out, part)
+ }
+ }
+ return out
+}
+
+func requiresTarget(kind string) bool {
+ switch kind {
+ case "page", "section", "article", "custom":
+ return true
+ default:
+ return false
+ }
+}
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
+}