diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 00000000..5fbb35eb
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,68 @@
+name: Pages
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+# Deployments queue rather than cancel: a half-published site is worse than a
+# slightly stale one.
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+env:
+ # Pinned rather than 'latest': the site depends on Hugo's mount and render-hook
+ # behaviour, and hugo.toml documents a v0.165.0 quirk in module.mounts.
+ HUGO_VERSION: 0.165.0
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ # The theme is a submodule; fetch-depth 0 gives every page its
+ # last-modified date from git history (enableGitInfo).
+ submodules: recursive
+ fetch-depth: 0
+
+ - name: Install Hugo
+ run: |
+ curl -sSLo hugo.deb "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb"
+ sudo dpkg -i hugo.deb
+
+ # Runs on pull requests too. docs/*.md keep plain relative links so they
+ # stay readable on github.com, which means a renamed page only shows up as
+ # a broken link here — the build is the check.
+ - name: Build
+ run: hugo --gc --minify
+
+ - name: Check every internal link resolves
+ run: ./scripts/check-site-links.py
+
+ - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
+ if: github.event_name != 'pull_request'
+ with:
+ path: ./public
+
+ deploy:
+ needs: build
+ if: github.event_name != 'pull_request'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
diff --git a/.gitignore b/.gitignore
index da4c741d..b6bff596 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,11 @@ __pycache__/
*.pyo
.venv/
+# Hugo (docs site build output and asset cache)
+/public/
+/resources/
+.hugo_build.lock
+
# Worktrees
.worktrees/
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..71b226e2
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,4 @@
+[submodule "themes/hugo-book"]
+ path = themes/hugo-book
+ url = https://github.com/alex-shpak/hugo-book
+ branch = main
diff --git a/Makefile b/Makefile
index 353404c2..d5cbee2d 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: build test codegen run clean test-compat docker-build docker-run changelog stats
+.PHONY: build test codegen run clean test-compat docker-build docker-run changelog stats docs-serve docs-build
build:
go build -o dist/devcloud ./cmd/devcloud
@@ -25,8 +25,21 @@ docker-build:
docker-run:
docker run -p 4747:4747 -v $(PWD)/data:/app/data devcloud/devcloud
+# Serves docs/ as the site published to skyoo2003.github.io/devcloud.
+# Requires the extended edition (the theme compiles SCSS) and the theme
+# submodule: git submodule update --init
+docs-serve:
+ hugo server --buildDrafts
+
+# What .github/workflows/pages.yml runs. The link check is not optional: the
+# render hook resolves unknown links to GitHub URLs instead of failing, so a
+# renamed page breaks quietly without it.
+docs-build:
+ hugo --gc --minify
+ ./scripts/check-site-links.py
+
clean:
- rm -rf dist/ data/
+ rm -rf dist/ data/ public/ resources/
changelog:
@if [ -z "$(VERSION)" ]; then \
diff --git a/changes/unreleased/Documentation-20260907-101500.yaml b/changes/unreleased/Documentation-20260907-101500.yaml
new file mode 100644
index 00000000..bcb8be1d
--- /dev/null
+++ b/changes/unreleased/Documentation-20260907-101500.yaml
@@ -0,0 +1,5 @@
+kind: Documentation
+body: The documentation is now published as a searchable site at https://skyoo2003.github.io/devcloud/, built with Hugo from the same `docs/*.md` files GitHub renders — no front matter added and no links rewritten.
+time: 2026-09-07T10:15:00.000000+09:00
+custom:
+ Issue: "154"
diff --git a/docs/contributing.md b/docs/contributing.md
index 217c2c42..628cb3ff 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -6,6 +6,7 @@
- Python 3.10+ with pip (compatibility tests)
- Docker (optional — Lambda runtime and integration tests)
- `pre-commit` (optional but recommended)
+- Hugo extended 0.165+ (optional — only to preview this documentation as a site)
No C toolchain and no SQLite headers: the SQLite driver is pure Go, which is why
everything here builds with `CGO_ENABLED=0`.
@@ -33,7 +34,9 @@ make run # starts the server on port 4747
| `make codegen-s3` | Same, restricted to S3 — fast loop while editing templates |
| `make docker-build` | Build the image from `docker/Dockerfile` as `devcloud/devcloud` |
| `make docker-run` | Run that image on port 4747 with `./data` mounted |
-| `make clean` | Delete `dist/` and `data/` |
+| `make docs-serve` | Preview this documentation locally with Hugo |
+| `make docs-build` | Build the documentation site and check every internal link |
+| `make clean` | Delete `dist/`, `data/`, and the Hugo output in `public/` and `resources/` |
| `make changelog VERSION=vX.Y.Z` | Batch and merge Changie fragments; `VERSION` is required |
| `make stats` | Print registered service and operation counts |
@@ -139,6 +142,29 @@ internal/
└── register.go # Plugin registration
```
+## Documentation
+
+Everything under `docs/` is read two ways: as plain markdown on github.com, and
+as the site at . Hugo mounts the directory
+rather than copying it, so both come from the same files.
+
+That dual audience sets one rule: **no front matter**. GitHub renders a YAML
+block as a metadata table above the page, so titles come from each document's
+H1 instead, and links stay plain and relative (`[coverage](coverage.md#tiers)`)
+rather than Hugo `ref` shortcodes.
+
+Adding a page takes two steps:
+
+1. Write `docs/.md` starting with an `# H1` — that becomes its title.
+2. Add it to `[menu.before]` in `hugo.toml`. The sidebar is defined there
+ because Hugo would otherwise sort pages alphabetically, and every entry needs
+ its own `identifier` — entries without one collapse into a single link.
+
+Then run `make docs-build`. Links that leave `docs/` — to source files or to
+root files like `CONTRIBUTING.md` — are rewritten to point at GitHub, which also
+means an unresolvable link fails silently; `scripts/check-site-links.py`, which
+`make docs-build` runs, is what catches it.
+
## Code Style
- Standard Go conventions (`gofmt`, `go vet`)
diff --git a/hugo.toml b/hugo.toml
new file mode 100644
index 00000000..3136f59a
--- /dev/null
+++ b/hugo.toml
@@ -0,0 +1,149 @@
+baseURL = 'https://skyoo2003.github.io/devcloud/'
+title = 'DevCloud'
+theme = 'hugo-book'
+enableGitInfo = true
+
+# docs/ is mounted rather than copied: the same files serve github.com readers
+# (README.md links straight into them) and this site, so they must stay free of
+# front matter and stay where the repository links expect them.
+#
+# README.md becomes the home page instead of a separate _index.md — it is
+# already the documentation index, and duplicating it would guarantee the two
+# drift apart.
+[[module.mounts]]
+ source = 'docs'
+ target = 'content'
+ # Without this, README.md is built a second time as /readme/, duplicating the
+ # home page. Hugo deprecated `excludeFiles` in v0.153.0 in favour of `files`,
+ # but as of v0.165.0 `files` does not filter mounts — every pattern tried
+ # ('!README.md', '{**,!README.md}', list forms) was silently ignored and let
+ # README.md through. Reordering the mounts so the file mount claims it first
+ # does not work either. Revisit when `files` actually filters; until then this
+ # is the only key that does the job, at the cost of one deprecation warning.
+ excludeFiles = ['README.md']
+
+[[module.mounts]]
+ source = 'docs/README.md'
+ target = 'content/_index.md'
+
+[markup]
+ [markup.goldmark.renderer]
+ unsafe = true
+ [markup.tableOfContents]
+ startLevel = 2
+ endLevel = 3
+
+# Sidebar order is defined here rather than by per-page `weight` front matter.
+# Hugo's default page sort is alphabetical, which buries Getting Started at
+# position ten; the alternative — adding front matter to all twenty documents —
+# makes github.com render a metadata table above every page.
+# The file-tree menu is disabled in layouts/_partials/docs/menu-filetree.html.
+#
+# Every entry needs an explicit `identifier`. Entries carrying only `pageRef`
+# all share the empty identifier and collapse into a single surviving link.
+# Names are omitted on purpose: the theme falls back to the page title, which
+# layouts/_partials/docs/title.html reads from each document's H1.
+[menu]
+ [[menu.before]]
+ identifier = 'getting-started'
+ pageRef = '/getting-started'
+ weight = 10
+ [[menu.before]]
+ identifier = 'configuration'
+ pageRef = '/configuration'
+ weight = 20
+ [[menu.before]]
+ identifier = 'architecture'
+ pageRef = '/architecture'
+ weight = 30
+ [[menu.before]]
+ identifier = 'coverage'
+ pageRef = '/coverage'
+ weight = 40
+ [[menu.before]]
+ identifier = 'compatibility-policy'
+ pageRef = '/compatibility-policy'
+ weight = 50
+ [[menu.before]]
+ identifier = 'fidelity-manifest'
+ pageRef = '/fidelity-manifest'
+ weight = 60
+ [[menu.before]]
+ identifier = 'crud-engine'
+ pageRef = '/crud-engine'
+ weight = 70
+
+ [[menu.before]]
+ identifier = 'services-matrix'
+ pageRef = '/services-matrix'
+ weight = 80
+ [[menu.before]]
+ identifier = 's3'
+ parent = 'services-matrix'
+ pageRef = '/services/s3'
+ weight = 81
+ [[menu.before]]
+ identifier = 'sqs'
+ parent = 'services-matrix'
+ pageRef = '/services/sqs'
+ weight = 82
+ [[menu.before]]
+ identifier = 'dynamodb'
+ parent = 'services-matrix'
+ pageRef = '/services/dynamodb'
+ weight = 83
+ [[menu.before]]
+ identifier = 'lambda'
+ parent = 'services-matrix'
+ pageRef = '/services/lambda'
+ weight = 84
+ [[menu.before]]
+ identifier = 'iam-sts'
+ parent = 'services-matrix'
+ pageRef = '/services/iam-sts'
+ weight = 85
+
+ [[menu.before]]
+ identifier = 'demand'
+ pageRef = '/demand'
+ weight = 90
+ [[menu.before]]
+ identifier = 'roadmap'
+ pageRef = '/roadmap'
+ weight = 100
+ [[menu.before]]
+ identifier = 'faq'
+ pageRef = '/faq'
+ weight = 110
+ [[menu.before]]
+ identifier = 'troubleshooting'
+ pageRef = '/troubleshooting'
+ weight = 120
+ [[menu.before]]
+ identifier = 'contributing'
+ pageRef = '/contributing'
+ weight = 130
+ [[menu.before]]
+ identifier = 'plugin-api'
+ pageRef = '/plugin-api'
+ weight = 140
+
+ [[menu.after]]
+ name = 'GitHub'
+ url = 'https://github.com/skyoo2003/devcloud'
+ weight = 10
+ [[menu.after]]
+ name = 'Changelog'
+ url = 'https://github.com/skyoo2003/devcloud/blob/main/CHANGELOG.md'
+ weight = 20
+
+[params]
+ BookTheme = 'auto'
+ BookSearch = true
+ BookComments = false
+ BookSection = '/'
+ BookRepo = 'https://github.com/skyoo2003/devcloud'
+ # .Path already arrives as docs/.md — it is derived from the file's
+ # path relative to the working directory, not to the mount target.
+ BookEditLink = '{{ .Site.Params.BookRepo }}/edit/main/{{ .Path }}'
+ BookLastChangeLink = '{{ .Site.Params.BookRepo }}/commit/{{ .GitInfo.Hash }}'
diff --git a/layouts/_markup/render-link.html b/layouts/_markup/render-link.html
new file mode 100644
index 00000000..b8b2d1e8
--- /dev/null
+++ b/layouts/_markup/render-link.html
@@ -0,0 +1,44 @@
+{{- /*
+ Overrides the theme's link hook so docs/*.md can keep the relative links that
+ make them readable on github.com.
+
+ Two kinds arrive here:
+ - 83 links inside docs/, e.g. `](coverage.md#tiers)` — resolved to the built
+ page, so the site is self-contained.
+ - 46 links that escape docs/, e.g. `](../internal/gateway/router.go)` and
+ `](../CONTRIBUTING.md)` — nothing outside the mount is built, so these
+ point at the file on GitHub rather than 404.
+
+ Anything unresolved falls through to the GitHub URL rather than failing the
+ build: a link to a source file is a normal thing for these docs to contain.
+*/ -}}
+{{- $destination := .Destination -}}
+{{- $url := urls.Parse $destination -}}
+
+{{- if and (not $url.IsAbs) (not (strings.HasPrefix $destination "#")) -}}
+ {{- $ref := strings.TrimPrefix "./" $url.Path -}}
+ {{- $ref = strings.TrimSuffix ".md" $ref -}}
+ {{- $ref = strings.TrimSuffix "/_index" $ref -}}
+
+ {{- with .Page.GetPage $ref -}}
+ {{- $destination = .RelPermalink -}}
+ {{- else -}}
+ {{- with $.Page.File -}}
+ {{- /* .Path is relative to the mount target, so re-add the docs/ prefix
+ before resolving the `../` segments against the repository root. */ -}}
+ {{- $repoPath := path.Join "docs" (path.Dir .Path) $url.Path -}}
+ {{- $kind := cond (strings.HasSuffix $url.Path "/") "tree" "blob" -}}
+ {{- $destination = printf "%s/%s/main/%s" $.Page.Site.Params.BookRepo $kind $repoPath -}}
+ {{- end -}}
+ {{- end -}}
+
+ {{- with $url.RawQuery -}}
+ {{- $destination = printf "%s?%s" $destination . -}}
+ {{- end -}}
+ {{- with $url.Fragment -}}
+ {{- $destination = printf "%s#%s" $destination . -}}
+ {{- end -}}
+{{- end -}}
+
+{{ .Text | safeHTML }}
+{{- /**/ -}}
diff --git a/layouts/_partials/docs/menu-filetree.html b/layouts/_partials/docs/menu-filetree.html
new file mode 100644
index 00000000..2ad513f0
--- /dev/null
+++ b/layouts/_partials/docs/menu-filetree.html
@@ -0,0 +1,8 @@
+{{- /*
+ Deliberately empty. The theme builds its sidebar from the content tree, which
+ Hugo sorts alphabetically when pages carry no `weight` — and docs/*.md carry
+ no front matter at all, on purpose, so they render cleanly on github.com.
+
+ The sidebar is defined by [menu.before] in hugo.toml instead, rendered by the
+ theme's docs/menu-hugo partial. Add new pages there, not here.
+*/ -}}
diff --git a/layouts/_partials/docs/title.html b/layouts/_partials/docs/title.html
new file mode 100644
index 00000000..07bfbead
--- /dev/null
+++ b/layouts/_partials/docs/title.html
@@ -0,0 +1,30 @@
+{{- /*
+ Overrides the theme's title partial to read each page's H1 instead of its
+ filename. Without this, titles come from the filename and read wrong —
+ "Crud Engine", "Faq", "Iam Sts" — and the fix the theme expects is a `title`
+ front matter key on all twenty documents, which github.com then renders as a
+ metadata table above every page.
+
+ Accepts Page as context, same as the partial it replaces.
+*/ -}}
+{{ $title := "" }}
+
+{{ with .RawContent }}
+ {{ with (findRESubmatch `(?m)^#[ \t]+(.+?)[ \t]*$` . 1) }}
+ {{ $title = index (index . 0) 1 }}
+ {{ end }}
+{{ end }}
+
+{{ if not $title }}
+ {{ if .LinkTitle }}
+ {{ $title = .LinkTitle }}
+ {{ else if .Title }}
+ {{ $title = .Title }}
+ {{ else if and .IsSection .File }}
+ {{ $title = path.Base .File.Dir | humanize | title }}
+ {{ else if and .IsPage .File }}
+ {{ $title = .File.BaseFileName | humanize | title }}
+ {{ end }}
+{{ end }}
+
+{{ return $title }}
diff --git a/scripts/check-site-links.py b/scripts/check-site-links.py
new file mode 100755
index 00000000..98d489f8
--- /dev/null
+++ b/scripts/check-site-links.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: Apache-2.0
+"""scripts/check-site-links.py — verify every internal link in the built site
+resolves to a page that exists.
+
+docs/*.md deliberately carry no front matter and no Hugo ref shortcodes: the
+same files are read on github.com, where `{{< ref >}}` is not markdown and a
+metadata table above every page is noise. They link each other with plain
+relative paths instead, and layouts/_markup/render-link.html turns those into
+site URLs at build time.
+
+That hook cannot fail loudly. Links leaving docs/ — `../internal/gateway/router.go`,
+`../CONTRIBUTING.md` — are legitimate and resolve to GitHub blob URLs, so the
+hook treats "no page found" as that case. A page renamed without its inbound
+links updated therefore does not break the build; it silently becomes a link to
+a file path on GitHub that may not exist either. Hugo's own refLinksErrorLevel
+does not apply, because nothing here uses refs.
+
+So the check runs after the build, against the output: every /devcloud/ URL the
+site emits must correspond to a file under public/. Exits non-zero with the
+offending link and the page it came from.
+"""
+
+from __future__ import annotations
+
+import pathlib
+import re
+import sys
+from urllib.parse import unquote, urlsplit
+
+PUBLIC = pathlib.Path(__file__).resolve().parent.parent / "public"
+
+# Minified output drops the quotes, so both forms have to match.
+HREF = re.compile(r"""(?:href|src)=(?:"([^"]*)"|'([^']*)'|([^\s>]+))""")
+
+
+def targets(html: str):
+ for match in HREF.finditer(html):
+ yield match.group(1) or match.group(2) or match.group(3)
+
+
+def main() -> int:
+ if not PUBLIC.is_dir():
+ print(f"{PUBLIC} not found — run `hugo` first", file=sys.stderr)
+ return 2
+
+ pages = sorted(PUBLIC.rglob("*.html"))
+ if not pages:
+ print(f"no HTML under {PUBLIC} — the build produced nothing", file=sys.stderr)
+ return 2
+
+ broken: list[tuple[str, str]] = []
+ checked = 0
+
+ for page in pages:
+ source = page.relative_to(PUBLIC)
+ for target in targets(page.read_text(encoding="utf-8")):
+ path = urlsplit(target).path
+ # Only site-internal links are ours to guarantee. Absolute URLs are
+ # the GitHub fallbacks and outbound references; fragments and
+ # mailto: have no file behind them.
+ if not path.startswith("/devcloud/"):
+ continue
+ checked += 1
+ relative = unquote(path[len("/devcloud/") :]).strip("/")
+ candidates = [PUBLIC / relative, PUBLIC / relative / "index.html"]
+ if not any(candidate.exists() for candidate in candidates):
+ broken.append((target, str(source)))
+
+ if broken:
+ print(f"{len(broken)} internal link(s) point at nothing:", file=sys.stderr)
+ for target, source in sorted(set(broken)):
+ print(f" {target} (from {source})", file=sys.stderr)
+ print(
+ "\nA page was probably renamed or removed without updating the "
+ "docs/*.md that link to it, or hugo.toml's [menu.before] still "
+ "lists it.",
+ file=sys.stderr,
+ )
+ return 1
+
+ print(f"{checked} internal links across {len(pages)} pages all resolve")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/themes/hugo-book b/themes/hugo-book
new file mode 160000
index 00000000..a2b885db
--- /dev/null
+++ b/themes/hugo-book
@@ -0,0 +1 @@
+Subproject commit a2b885db6d061aa8533f78e885386b17d815f8cc