diff --git a/AGENTS.md b/AGENTS.md index 958b354..4ab3779 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,8 +145,9 @@ castle apply # served at my-frontend. ``` The gateway serves the build **in place** from `/` — no copy, no -Node process. Stack: **`docs/stacks/react-vite.md`**. Database-backed apps on the -shared Supabase substrate: **`docs/stacks/supabase.md`**. +Node process. Stack: **`docs/stacks/react-vite.md`**. Content-driven static sites +built by Hugo: **`docs/stacks/hugo.md`**. Database-backed apps on the shared +Supabase substrate: **`docs/stacks/supabase.md`**. ### Adopt an existing repo (no stack needed) @@ -324,6 +325,7 @@ Inspect + drive from the CLI: **`castle mesh status`** / **`castle mesh nodes`** | Writing FastAPI services | **`docs/stacks/python-fastapi.md`** | | Writing CLI tools | **`docs/stacks/python-cli.md`** | | Writing React/Vite frontends | **`docs/stacks/react-vite.md`** | +| Writing Hugo static sites | **`docs/stacks/hugo.md`** | | Database-backed apps (shared Supabase) | **`docs/stacks/supabase.md`** | | **Developing Castle itself** (CLI/core/api/app, key files, endpoints) | **`docs/developing-castle.md`** | diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index 2190ed5..173e5fd 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -29,14 +29,16 @@ STACK_DEFAULTS: dict[str, str] = { "python-cli": "tool", "react-vite": "static", "supabase": "static", + "hugo": "static", } # Static build output per stack, for `static` (caddy) deployments. The gateway # serves this dir in place at . (no service, no process). -# A supabase app ships a raw `public/`; react-vite builds to `dist/`. +# A supabase app ships a raw `public/`; react-vite builds to `dist/`; hugo to `public/`. STACK_BUILD_OUTPUTS: dict[str, str] = { "supabase": "public", "react-vite": "dist", + "hugo": "public", } # Substrate a stack's apps depend on — seeded as a deployment `requires` at creation @@ -180,6 +182,10 @@ def run_create(args: argparse.Namespace) -> int: print(" # edit migrations/, functions/, public/ — targets the shared substrate") print(f" castle program build {name} # apply migrations to the substrate") print(f" castle apply # serve at {name}.") + elif stack == "hugo": + print(" # edit content/, layouts/ (or add a theme under themes/)") + print(f" castle program build {name} # hugo --gc --minify -> public/") + print(f" castle apply {name} # serve at {name}.") elif stack: print(" uv sync") if kind == "service": diff --git a/cli/src/castle_cli/templates/scaffold.py b/cli/src/castle_cli/templates/scaffold.py index 2b3895f..319dc74 100644 --- a/cli/src/castle_cli/templates/scaffold.py +++ b/cli/src/castle_cli/templates/scaffold.py @@ -2,6 +2,8 @@ from __future__ import annotations +import shutil +import subprocess from pathlib import Path @@ -20,6 +22,8 @@ def scaffold_project( _scaffold_tool(project_dir, name, package_name, description) elif stack == "supabase": _scaffold_supabase(project_dir, name, description) + elif stack == "hugo": + _scaffold_hugo(project_dir, name, description) else: raise ValueError(f"No scaffold template for stack: {stack}") @@ -771,6 +775,200 @@ Auth/WebCrypto apps should get their own HTTPS host route (secure context). ) +def _hugo_new_site(project_dir: Path) -> bool: + """Create the canonical site skeleton with Hugo's own scaffolder. + + Delegates the standard structure (archetypes/, the content/layouts/static/… + dir tree, hugo.toml) to `hugo new site` rather than reinventing it. Returns + False if the hugo binary isn't present at create time — the caller then falls + back to a minimal hand-written skeleton (the build needs hugo regardless).""" + if shutil.which("hugo") is None: + return False + # `hugo new site` requires an empty/absent target; create runs before git init, + # so project_dir doesn't exist yet. + result = subprocess.run( + ["hugo", "new", "site", str(project_dir), "--format", "toml"], + capture_output=True, + text=True, + ) + return result.returncode == 0 + + +def _scaffold_hugo(project_dir: Path, name: str, description: str) -> None: + """Scaffold a Hugo site that builds and serves theme-less out of the box. + + The canonical skeleton comes from Hugo's own `hugo new site`; on top of it we + overlay the pieces a bare skeleton lacks — the layouts needed to render a home + page without a theme, sample content, and a castle-flavored hugo.toml/README. + + `castle program build` runs `hugo --gc --minify` → `public/`, which a caddy + deployment serves in place at .. baseURL is `/` so assets + resolve at the root of the site's own subdomain. A theme (with its own asset + pipeline) can be dropped under themes/ later; such themes declare a two-step + `build.commands` in the program spec, which overrides the stack default.""" + + def sub(text: str) -> str: + return text.replace("__NAME__", name).replace("__DESC__", description) + + # Prefer Hugo's native scaffolder; fall back to the bits it would have made. + if not _hugo_new_site(project_dir): + _write( + project_dir / "archetypes" / "default.md", + """+++ +date = '{{ .Date }}' +draft = true +title = '{{ replace .File.ContentBaseName "-" " " | title }}' ++++ +""", + ) + + # --- hugo.toml — overwrite Hugo's example.org default; baseURL '/' serves at + # the subdomain root --- + _write( + project_dir / "hugo.toml", + sub( + """baseURL = "/" +languageCode = "en-us" +title = "__NAME__" + +[params] +description = "__DESC__" +""" + ), + ) + + # --- layouts/_default/baseof.html — the shared page skeleton (blocks) --- + _write( + project_dir / "layouts" / "_default" / "baseof.html", + sub( + """ + + + + + {{ block "title" . }}{{ .Site.Title }}{{ end }} + + + +
{{ .Site.Title }}
+
{{ block "main" . }}{{ end }}
+
© {{ now.Year }} {{ .Site.Title }}
+ + +""" + ), + ) + + # --- layouts/index.html — the home page --- + _write( + project_dir / "layouts" / "index.html", + """{{ define "main" }} + {{ .Content }} +
    + {{ range (where .Site.RegularPages "Type" "posts") }} +
  • + {{ .Title }} + +
  • + {{ end }} +
+{{ end }} +""", + ) + + # --- layouts/_default/{single,list}.html — content pages --- + _write( + project_dir / "layouts" / "_default" / "single.html", + """{{ define "title" }}{{ .Title }} · {{ .Site.Title }}{{ end }} +{{ define "main" }} +
+

{{ .Title }}

+ + {{ .Content }} +
+{{ end }} +""", + ) + _write( + project_dir / "layouts" / "_default" / "list.html", + """{{ define "title" }}{{ .Title }} · {{ .Site.Title }}{{ end }} +{{ define "main" }} +

{{ .Title }}

+ {{ .Content }} + +{{ end }} +""", + ) + + # --- content — home + an example post --- + _write( + project_dir / "content" / "_index.md", + sub( + """--- +title: "__NAME__" +--- + +Welcome to **__NAME__** — a Hugo site on castle. Edit `content/_index.md` and +add posts under `content/posts/`. +""" + ), + ) + _write( + project_dir / "content" / "posts" / "hello.md", + """--- +title: "Hello, world" +date: 2024-01-01 +--- + +Your first post. Edit me in `content/posts/`, or run `hugo new posts/next.md`. +""", + ) + + # --- .gitignore — build output is regenerated, never committed --- + _write( + project_dir / ".gitignore", + "/public/\n/resources/\n.hugo_build.lock\n", + ) + + # --- README --- + _write( + project_dir / "README.md", + sub( + """# __NAME__ + +__DESC__ + +A [Hugo](https://gohugo.io) static site managed by castle. + +## Develop + + hugo server -D # live preview at http://localhost:1313 + +## Build & serve + + castle program build __NAME__ # hugo --gc --minify -> public/ + castle apply __NAME__ # serve at __NAME__. + +## Themes + +Drop a theme under `themes/` (often a git submodule) and set `theme` in +`hugo.toml`. A theme with an asset pipeline (Tailwind, etc.) needs a pre-build +step; declare it in `programs/__NAME__.yaml` so it overrides the stack default: + + build: + commands: + - [pnpm, build] + - [hugo, --gc, --minify] + outputs: [public] +""" + ), + ) + + def _write(path: Path, content: str) -> None: """Write content to a file, creating parent directories.""" path.parent.mkdir(parents=True, exist_ok=True) diff --git a/core/src/castle_core/stacks.py b/core/src/castle_core/stacks.py index 0185d09..6cd2583 100644 --- a/core/src/castle_core/stacks.py +++ b/core/src/castle_core/stacks.py @@ -111,6 +111,14 @@ class StackHandler: # stacks whose `teardown` actually destroys something. owns_data: bool = False + # The dev verbs this stack advertises — what `available_actions` offers and + # `run_action` will dispatch. Defaults to every stack verb (the python/react/ + # supabase handlers implement them all); a narrow stack like hugo (build-only, + # no native lint/test/type-check) overrides this so callers aren't offered verbs + # that would only error. `check` composes the sub-verbs, so a handler that drops + # lint/type-check/test also drops `check` implicitly. + provides: set[str] = _STACK_VERBS + async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: raise NotImplementedError @@ -374,6 +382,59 @@ class ReactViteHandler(StackHandler): ) +class HugoHandler(StackHandler): + """Handler for the hugo stack — a static site built by the Hugo generator. + + Hugo has one real dev verb: **build** (`hugo --gc --minify` → `public/`), which + a `caddy` deployment then serves in place at `.`. There is + no native lint/test/type-check, so `provides` is narrowed to the verbs that do + something. A theme with an asset pipeline (e.g. Blowfish + Tailwind) declares its + own two-step `build.commands` (`pnpm build` then `hugo …`), which override this + default per the usual declared-command-wins resolution.""" + + provides = {"build", "install", "uninstall"} + + async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: + src = _source_dir(comp, root) + rc, output = await _run(["hugo", "--gc", "--minify"], src) + return ActionResult( + program=name, + action="build", + status="ok" if rc == 0 else "error", + output=output, + ) + + async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: + """Build the site in place. The gateway serves it directly from + / (default `public/`) — no copy step.""" + result = await self.build(name, comp, root) + if result.status != "ok": + return ActionResult( + program=name, + action="install", + status="error", + output=f"Build failed:\n{result.output}", + ) + outputs = comp.build.outputs if comp.build else [] + dist = _source_dir(comp, root) / (outputs[0] if outputs else "public") + return ActionResult( + program=name, + action="install", + status="ok", + output=f"Built; served in place from {dist}", + ) + + async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: + """Static sites have no install footprint — served in place. Deactivating one + means dropping its gateway route, not deleting build output.""" + return ActionResult( + program=name, + action="uninstall", + status="ok", + output=f"{name}: served in place; nothing to uninstall.", + ) + + def _migration_version(path: Path) -> str: """The version key of a migration file — the leading token before '_'. @@ -655,6 +716,7 @@ HANDLERS: dict[str, StackHandler] = { "python-fastapi": PythonHandler(), "react-vite": ReactViteHandler(), "supabase": SupabaseHandler(), + "hugo": HugoHandler(), } @@ -687,12 +749,12 @@ def _declared_commands(comp: ProgramSpec, verb: str) -> list[list[str]] | None: def _stack_provides(comp: ProgramSpec, verb: str) -> bool: - """Whether the program's stack handler can run this verb.""" - return ( - bool(comp.source) - and verb in _STACK_VERBS - and get_handler(comp.stack) is not None - ) + """Whether the program's stack handler advertises this verb. + + Consults the handler's ``provides`` set (default: all of ``_STACK_VERBS``) so a + build-only stack like hugo doesn't offer lint/test/type-check it can't run.""" + handler = get_handler(comp.stack) + return bool(comp.source) and handler is not None and verb in handler.provides def is_available(comp: ProgramSpec, verb: str) -> bool: diff --git a/docs/stacks/hugo.md b/docs/stacks/hugo.md new file mode 100644 index 0000000..37c367c --- /dev/null +++ b/docs/stacks/hugo.md @@ -0,0 +1,96 @@ +# Hugo static sites in Castle + +> **This is a stack — creation-time guidance for writing _new_ sites.** +> A stack is a template + conventions, not a runtime requirement. `castle program +> create --stack hugo` scaffolds from it (via Hugo's own `hugo new site`) and seeds +> the program's default build verb. An existing Hugo site adopted with `castle +> program add` doesn't need this stack — it declares its own `commands:` / +> `build:`. See @docs/registry.md for `commands:`, `stack:` (optional), and `repo:`. + +How to build, serve, and manage [Hugo](https://gohugo.io) sites as castle programs. + +## Stack + +- Generator: Hugo (extended recommended — needed for SCSS/asset processing) +- Build: `hugo --gc --minify` → `public/` +- Served: `manager: caddy` static deployment, in place at `.` +- Package manager (only if a theme needs an asset pipeline): pnpm + +Hugo has one meaningful dev verb — **build**. It has no native lint/test/type-check, +so the stack advertises only `build` / `install` / `uninstall`; `castle check` and +friends aren't offered (a site can still declare its own, e.g. an HTML linter, under +`commands:` — a declared verb always wins over the stack). + +## Create a new site + +```bash +castle program create my-site --stack hugo --description "My site" +cd /data/repos/my-site +castle program build my-site # hugo --gc --minify -> public/ +castle apply my-site # serve at my-site. +``` + +The scaffold delegates the canonical skeleton to `hugo new site` (archetypes/, +content/, layouts/, static/, themes/, hugo.toml) and overlays the pieces a bare +skeleton lacks: minimal `layouts/` so the site **builds and serves without a +theme**, an example `content/posts/hello.md`, a castle-flavored `hugo.toml` +(`baseURL = "/"`, so assets resolve at the root of the site's own subdomain), and a +`.gitignore` for the regenerated `public/` and `resources/`. + +Develop with the live server: + +```bash +hugo server -D # http://localhost:1313, rebuilds on save +``` + +## Adding a theme + +Drop a theme under `themes/` (usually a git submodule) and set `theme` in +`hugo.toml`: + +```bash +git submodule add https://github.com//.git themes/ +``` + +Themes with an **asset pipeline** (e.g. Blowfish + Tailwind) need a pre-build step +before `hugo`. Declare it as a two-step `build` in `programs/.yaml` — a +declared `build.commands` overrides the stack's single-step default: + +```yaml +build: + commands: + - [pnpm, build] # compile the theme's CSS/JS + - [hugo, --gc, --minify] # render the site -> public/ + outputs: [public] +``` + +One-time setup those themes expect (run once in the source tree): + +```bash +git submodule update --init --recursive +cd themes/ && pnpm install +``` + +## Deployment shape + +`castle program create --stack hugo` writes: + +- **`programs/.yaml`** — `source`, `stack: hugo`, `build.outputs: [public]`. +- **`deployments/statics/.yaml`** — `manager: caddy`, `root: public`, + `reach: internal` (flip to `public` to also expose over the tunnel). + +The gateway serves `/public` in place — no copy, no Node/Hugo process at +runtime. `castle program build` regenerates `public/`; `castle apply` renders the +route and reloads the gateway. + +## Adopting an existing Hugo site + +No stack needed — adopt the repo and declare how it builds: + +```bash +castle program add /path/to/site --name my-site +``` + +Then set `build.commands` (as above) and add a `manager: caddy` deployment. This is +how a site with a bespoke build (submodule theme + Tailwind) is wired without the +scaffold. See @docs/registry.md.