diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-07-07 16:24:28 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-07-07 16:24:28 +0200 |
| commit | 37f156c30e2a2b01c840d39f0dd2bdbf811732d6 (patch) | |
| tree | e2ce84dc7f88ce0eb312899448f91b7bb93ca8a3 | |
| download | gemcms-main.tar.gz gemcms-main.tar.xz gemcms-main.zip | |
| -rw-r--r-- | AGENTS.md | 19 | ||||
| -rw-r--r-- | PROJECT_CONTEXT.md | 35 | ||||
| -rw-r--r-- | README.md | 96 | ||||
| -rw-r--r-- | TODO.md | 18 | ||||
| -rw-r--r-- | cmd/gemcms/main.go | 16 | ||||
| -rw-r--r-- | docs/decisions.md | 70 | ||||
| -rw-r--r-- | docs/help.md | 430 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | internal/cli/cli.go | 497 | ||||
| -rw-r--r-- | internal/cms/build.go | 274 | ||||
| -rw-r--r-- | internal/cms/check.go | 162 | ||||
| -rw-r--r-- | internal/cms/cms_test.go | 186 | ||||
| -rw-r--r-- | internal/cms/config.go | 40 | ||||
| -rw-r--r-- | internal/cms/content.go | 166 | ||||
| -rw-r--r-- | internal/cms/import.go | 82 | ||||
| -rw-r--r-- | internal/cms/markdown.go | 43 | ||||
| -rw-r--r-- | internal/cms/menu.go | 193 | ||||
| -rw-r--r-- | internal/cms/project.go | 407 | ||||
| -rw-r--r-- | internal/cms/section.go | 54 | ||||
| -rw-r--r-- | internal/cms/simpletoml.go | 172 | ||||
| -rw-r--r-- | internal/cms/slug.go | 51 | ||||
| -rw-r--r-- | internal/cms/status.go | 50 | ||||
| -rw-r--r-- | internal/cms/types.go | 174 | ||||
| -rw-r--r-- | internal/cms/widget.go | 106 |
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 }} +``` @@ -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. @@ -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 +} |
