Adds hugo stack.
This commit is contained in:
@@ -145,8 +145,9 @@ castle apply # served at my-frontend.<domain>
|
||||
```
|
||||
|
||||
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
|
||||
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`** |
|
||||
|
||||
|
||||
@@ -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 <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] = {
|
||||
"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}.<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:
|
||||
print(" uv sync")
|
||||
if kind == "service":
|
||||
|
||||
@@ -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 <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>© {{ 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 }} · {{ .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 }} · {{ .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:
|
||||
"""Write content to a file, creating parent directories."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -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 `<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:
|
||||
"""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:
|
||||
|
||||
96
docs/stacks/hugo.md
Normal file
96
docs/stacks/hugo.md
Normal 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.
|
||||
Reference in New Issue
Block a user