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