Adds hugo stack.

This commit is contained in:
2026-07-12 14:29:03 -07:00
parent a3ff03fc74
commit f8e487071e
5 changed files with 373 additions and 9 deletions

View File

@@ -145,8 +145,9 @@ castle apply # served at my-frontend.<domain>
``` ```
The gateway serves the build **in place** from `<source>/<root>` — no copy, no The gateway serves the build **in place** from `<source>/<root>` — no copy, no
Node process. Stack: **`docs/stacks/react-vite.md`**. Database-backed apps on the Node process. Stack: **`docs/stacks/react-vite.md`**. Content-driven static sites
shared Supabase substrate: **`docs/stacks/supabase.md`**. 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) ### 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 FastAPI services | **`docs/stacks/python-fastapi.md`** |
| Writing CLI tools | **`docs/stacks/python-cli.md`** | | Writing CLI tools | **`docs/stacks/python-cli.md`** |
| Writing React/Vite frontends | **`docs/stacks/react-vite.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`** | | Database-backed apps (shared Supabase) | **`docs/stacks/supabase.md`** |
| **Developing Castle itself** (CLI/core/api/app, key files, endpoints) | **`docs/developing-castle.md`** | | **Developing Castle itself** (CLI/core/api/app, key files, endpoints) | **`docs/developing-castle.md`** |

View File

@@ -29,14 +29,16 @@ STACK_DEFAULTS: dict[str, str] = {
"python-cli": "tool", "python-cli": "tool",
"react-vite": "static", "react-vite": "static",
"supabase": "static", "supabase": "static",
"hugo": "static",
} }
# Static build output per stack, for `static` (caddy) deployments. The gateway # Static build output per stack, for `static` (caddy) deployments. The gateway
# serves this dir in place at <name>.<gateway.domain> (no service, no process). # serves this dir in place at <name>.<gateway.domain> (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] = { STACK_BUILD_OUTPUTS: dict[str, str] = {
"supabase": "public", "supabase": "public",
"react-vite": "dist", "react-vite": "dist",
"hugo": "public",
} }
# Substrate a stack's apps depend on — seeded as a deployment `requires` at creation # 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(" # edit migrations/, functions/, public/ — targets the shared substrate")
print(f" castle program build {name} # apply migrations to the substrate") print(f" castle program build {name} # apply migrations to the substrate")
print(f" castle apply # serve at {name}.<gateway.domain>") print(f" castle apply # serve at {name}.<gateway.domain>")
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}.<gateway.domain>")
elif stack: elif stack:
print(" uv sync") print(" uv sync")
if kind == "service": if kind == "service":

View File

@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import shutil
import subprocess
from pathlib import Path from pathlib import Path
@@ -20,6 +22,8 @@ def scaffold_project(
_scaffold_tool(project_dir, name, package_name, description) _scaffold_tool(project_dir, name, package_name, description)
elif stack == "supabase": elif stack == "supabase":
_scaffold_supabase(project_dir, name, description) _scaffold_supabase(project_dir, name, description)
elif stack == "hugo":
_scaffold_hugo(project_dir, name, description)
else: else:
raise ValueError(f"No scaffold template for stack: {stack}") 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 <name>.<gateway.domain>. 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(
"""<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode | default "en" }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" . }}{{ .Site.Title }}{{ end }}</title>
<meta name="description" content="{{ .Site.Params.description }}">
</head>
<body>
<header><a href="{{ "/" | relURL }}">{{ .Site.Title }}</a></header>
<main>{{ block "main" . }}{{ end }}</main>
<footer>&copy; {{ now.Year }} {{ .Site.Title }}</footer>
</body>
</html>
"""
),
)
# --- layouts/index.html — the home page ---
_write(
project_dir / "layouts" / "index.html",
"""{{ define "main" }}
{{ .Content }}
<ul>
{{ range (where .Site.RegularPages "Type" "posts") }}
<li>
<a href="{{ .RelPermalink }}">{{ .Title }}</a>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "Jan 2, 2006" }}</time>
</li>
{{ end }}
</ul>
{{ end }}
""",
)
# --- layouts/_default/{single,list}.html — content pages ---
_write(
project_dir / "layouts" / "_default" / "single.html",
"""{{ define "title" }}{{ .Title }} &middot; {{ .Site.Title }}{{ end }}
{{ define "main" }}
<article>
<h1>{{ .Title }}</h1>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "Jan 2, 2006" }}</time>
{{ .Content }}
</article>
{{ end }}
""",
)
_write(
project_dir / "layouts" / "_default" / "list.html",
"""{{ define "title" }}{{ .Title }} &middot; {{ .Site.Title }}{{ end }}
{{ define "main" }}
<h1>{{ .Title }}</h1>
{{ .Content }}
<ul>
{{ range .Pages }}
<li><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
{{ end }}
</ul>
{{ 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__.<gateway.domain>
## 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: def _write(path: Path, content: str) -> None:
"""Write content to a file, creating parent directories.""" """Write content to a file, creating parent directories."""
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)

View File

@@ -111,6 +111,14 @@ class StackHandler:
# stacks whose `teardown` actually destroys something. # stacks whose `teardown` actually destroys something.
owns_data: bool = False 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: async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
raise NotImplementedError 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 `<name>.<gateway.domain>`. 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
<source>/<build.outputs[0]> (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: def _migration_version(path: Path) -> str:
"""The version key of a migration file — the leading token before '_'. """The version key of a migration file — the leading token before '_'.
@@ -655,6 +716,7 @@ HANDLERS: dict[str, StackHandler] = {
"python-fastapi": PythonHandler(), "python-fastapi": PythonHandler(),
"react-vite": ReactViteHandler(), "react-vite": ReactViteHandler(),
"supabase": SupabaseHandler(), "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: def _stack_provides(comp: ProgramSpec, verb: str) -> bool:
"""Whether the program's stack handler can run this verb.""" """Whether the program's stack handler advertises this verb.
return (
bool(comp.source) Consults the handler's ``provides`` set (default: all of ``_STACK_VERBS``) so a
and verb in _STACK_VERBS build-only stack like hugo doesn't offer lint/test/type-check it can't run."""
and get_handler(comp.stack) is not None 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: def is_available(comp: ProgramSpec, verb: str) -> bool:

96
docs/stacks/hugo.md Normal file
View File

@@ -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 `<name>.<gateway.domain>`
- 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.<gateway.domain>
```
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/<owner>/<theme>.git themes/<theme>
```
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/<name>.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/<theme> && pnpm install
```
## Deployment shape
`castle program create --stack hugo` writes:
- **`programs/<name>.yaml`** — `source`, `stack: hugo`, `build.outputs: [public]`.
- **`deployments/statics/<name>.yaml`** — `manager: caddy`, `root: public`,
`reach: internal` (flip to `public` to also expose over the tunnel).
The gateway serves `<source>/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.