From c5195d3c61c797885ff6401c03d6497e3b037ffa Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:18:42 +0100 Subject: [PATCH 1/2] feat(pages): deploy the sample site and fail CI on an empty build Pages for this repo is already set to build from GitHub Actions, but no job deployed the uploaded artifact, so https://hyperpolymath.github.io/ddraig-ssg has always returned 404. Add a `deploy` job (pushes to main only) using actions/deploy-pages v5.0.1, pinned by SHA; it is a node24 action with no nested actions, so it satisfies the SHA-pin policy. A project Pages site is served under /ddraig-ssg/, but the default template linked its stylesheet and home page root-relatively (/assets/style.css, /), so the deployed pages would have had no styles and a broken Home link. Add a {{root}} placeholder: the path part of the base URL ("/ddraig-ssg" here, "" for a domain-root site or no base URL), substituted by applyTemplate. Sites without a sub-path, and custom templates that don't use {{root}}, render exactly as before. The example's own link becomes relative (about.html), and the README documents both. Also add a "Check sample site" CI step. `ddraig build` exits 0 when it finds no Markdown sources, which is how the .md -> .adoc rename in #17 built an empty site and still went green; the step now fails on a missing or empty index.html/about.html, and on a stylesheet link without the sub-path. test-full now asserts basePath on five base URLs and that a rooted render prefixes the stylesheet link, exiting non-zero otherwise. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++-- Ddraig.idr | 53 +++++++++++++++++++++++++++++++++------- README.adoc | 7 ++++++ examples/index.md | 2 +- 4 files changed, 84 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45f4c52..68a2a95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,9 +51,18 @@ jobs: - name: Compile Ddraig run: idris2 Ddraig.idr -o ddraig - name: Build sample site - # base-url makes sitemap.xml/feed.xml absolute (valid). Change if the - # site moves to a custom domain. + # base-url makes sitemap.xml/feed.xml absolute (valid), and its path + # (/ddraig-ssg) prefixes the template's root-relative links. Change if + # the site moves to a custom domain. run: ./build/exec/ddraig build examples _site https://hyperpolymath.github.io/ddraig-ssg + - name: Check sample site + # `ddraig build` exits 0 when it finds no Markdown sources, so the + # .md -> .adoc rename in #17 built an empty site and still passed. + # Fail on that, and on links that would miss the Pages sub-path. + run: | + test -s _site/index.html + test -s _site/about.html + grep -q 'href="/ddraig-ssg/assets/style.css"' _site/index.html - name: Upload artifact # v5 pins its inner actions/upload-artifact to a commit SHA; v3 used the # `@v4` tag, which the SHA-pin policy rejects at job set-up. v4+ drops @@ -63,3 +72,24 @@ jobs: with: path: _site include-hidden-files: true + deploy: + # Publish the sample site. Pages is set to build from GitHub Actions, so + # this job is what puts https://hyperpolymath.github.io/ddraig-ssg online. + # Pushes to main only: pull requests build and upload, but never deploy. + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pages: write # create the Pages deployment + id-token: write # OIDC token that deploy-pages exchanges for it + concurrency: + group: pages + cancel-in-progress: false + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/Ddraig.idr b/Ddraig.idr index 440d0c1..62b04a6 100644 --- a/Ddraig.idr +++ b/Ddraig.idr @@ -536,7 +536,9 @@ buildToc hs = -- Canonical AAA default template (WCAG 2.2 AAA-capable engine-owned surface): -- skip link, , labelled landmarks, main#main, viewport, stylesheet -- link. Placeholders: {{title}} {{brand}} {{description}} {{date}} {{toc}} --- {{content}} — content substituted LAST so its body HTML is never reprocessed. +-- {{root}} {{content}} — content substituted LAST so its body HTML is never +-- reprocessed. {{root}} is the site's path prefix (see `basePath`), so the +-- root-relative stylesheet and home links also work under a sub-path. defTemplate : String defTemplate = """ @@ -556,16 +558,16 @@ defTemplate = """ - + @@ -748,15 +750,16 @@ stripPlaceholders s = pack (go (unpack s)) else c :: go rest export -applyTemplate : String -> Frontmatter -> (canonical : String) -> String -> String -> String -applyTemplate template fm canonical htmlContent tocHtml = +applyTemplate : String -> Frontmatter -> (root : String) -> (canonical : String) -> String -> String -> String +applyTemplate template fm root canonical htmlContent tocHtml = let brand = if fm.brand == "" then fm.title else fm.brand t1 = strReplace template "{{title}}" (strEscape fm.title) t2 = strReplace t1 "{{date}}" (strEscape fm.date) t3 = strReplace t2 "{{description}}" (strEscape fm.description) t4 = strReplace t3 "{{brand}}" (strEscape brand) tc = strReplace t4 "{{canonical}}" (strEscape canonical) - t5 = strReplace tc "{{toc}}" tocHtml + tr = strReplace tc "{{root}}" (strEscape root) + t5 = strReplace tr "{{toc}}" tocHtml -- strip any remaining unfilled placeholders BEFORE injecting content, -- so author content (which may legitimately contain braces) is untouched t6 = stripPlaceholders t5 @@ -917,6 +920,27 @@ absUrl base path = if base == "" then path else (if strHasSuffix "/" base then substr 0 (length base `minus` 1) base else base) ++ path +-- The site's path prefix: the path part of the base URL without a trailing +-- slash. "https://example.org/docs/" gives "/docs"; a site at the domain root, +-- or no base URL, gives "". Templates use it as {{root}} for root-relative +-- links, so a project site served under a sub-path (GitHub Pages serves +-- //) still finds its stylesheet and home page. +basePath : (base : String) -> String +basePath base = pack (reverse (dropSlashes (reverse (pathOf (afterScheme (unpack base)))))) + where + afterScheme : List Char -> List Char + afterScheme (':' :: '/' :: '/' :: rest) = rest + afterScheme (_ :: rest) = afterScheme rest + afterScheme [] = [] + -- drop the host, keeping everything from the first '/' + pathOf : List Char -> List Char + pathOf [] = [] + pathOf cs@('/' :: _) = cs + pathOf (_ :: rest) = pathOf rest + dropSlashes : List Char -> List Char + dropSlashes ('/' :: rest) = dropSlashes rest + dropSlashes cs = cs + -- Build one markdown file. Returns Maybe Page (Nothing if draft skipped). -- Build one markdown file. Returns the Page plus its O3 certification status -- (certified?, violations). Nothing only when the file is a draft or unreadable. @@ -938,7 +962,7 @@ buildOne src out base rel = do let fm2 = { title := title } fm let relHtml = toHtmlPath rel let canonical = absUrl base ("/" ++ relHtml) - let html = applyTemplate tpl fm2 canonical md.body toc + let html = applyTemplate tpl fm2 (basePath base) canonical md.body toc let outPath = joinPath out relHtml _ <- mkDirP (dirOf outPath) w <- writeFile outPath html @@ -1224,8 +1248,19 @@ testFull = do let content = "---\ntitle: Welcome\ndate: 2024-01-15\n---\n\n# Welcome\n\nThis is **Ddraig**, an Idris 2-powered SSG.\n\n- Dependently typed\n- Provably correct\n" let (fm, body) = parseFrontmatter content let md = parseMarkdownFull body - let output = applyTemplate defTemplate fm "/welcome.html" md.body (buildToc md.toc) + let output = applyTemplate defTemplate fm "" "/welcome.html" md.body (buildToc md.toc) putStrLn output + -- {{root}}: a sub-path site's links must carry its prefix. + let cases = [ ("", ""), ("https://example.com", ""), ("https://example.com/", "") + , ("https://hyperpolymath.github.io/ddraig-ssg", "/ddraig-ssg") + , ("https://example.org/a/b/", "/a/b") ] + let wrong = filter (\(b, want) => basePath b /= want) cases + let rooted = applyTemplate defTemplate fm "/sub" "/sub/welcome.html" md.body "" + let styled = strReplace rooted "href=\"/sub/assets/style.css\"" "" /= rooted + if null wrong && styled + then putStrLn "OK: basePath and {{root}}" + else do putStrLn ("FAIL: basePath/{{root}} " ++ show wrong ++ " stylesheet-prefixed=" ++ show styled) + exitFailure -- ============================================================================ -- a2ml STATE descriptile validation (O1) diff --git a/README.adoc b/README.adoc index d8be27c..c613469 100644 --- a/README.adoc +++ b/README.adoc @@ -28,6 +28,7 @@ toc::[] markup. * *Templating* — file-based layouts (`templates/.html`), partials (`{{> name}}` from `templates/partials/`), a `{{toc}}` table of contents, + a `{{root}}` path prefix for root-relative links (see `base-url` below), and a built-in accessible fallback template. * *Assets* — copies `assets/`, `static/`, `css/`, `js/`, `images/`, and `public/` (the latter to the site root, so `/.well-known/security.txt`, @@ -102,6 +103,12 @@ absolute `` URLs to be valid, and the feed embeds absolute ``/`` values; without a base URL the feed still validates (it falls back to `urn:` ids) but the sitemap locations are relative. +If the site is served under a sub-path, include it in `base-url` (for a GitHub +Pages project site, `https://.github.io/`). Its path becomes +`{{root}}` (here `/`), which the default template puts in front of its +stylesheet and home links. Write links between your own pages as relative +paths (`about.html`), not root-relative ones (`/about.html`). + A minimal example site lives in `examples/`: [source,bash] diff --git a/examples/index.md b/examples/index.md index 7a310c1..a711faf 100644 --- a/examples/index.md +++ b/examples/index.md @@ -27,4 +27,4 @@ main : IO () main = putStrLn "Ddraig" ``` -See the [getting-started page](/about.html) and the project [README](https://github.com/hyperpolymath/ddraig-ssg). +See the [getting-started page](about.html) and the project [README](https://github.com/hyperpolymath/ddraig-ssg). From 02ced6091145ca2ac65a35f20c217e3a989761f5 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:41:22 +0000 Subject: [PATCH 2/2] ci: cancel superseded runs so an older build cannot deploy last The deploy job's `pages` group serialises deployments but not the builds before them, so two quick pushes to main could deploy the older build after the newer one. A workflow-level group per ref, as secret-scanner.yml already uses, cancels the superseded run instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015pMjy981ie74uCttyYDanC --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68a2a95..763eda0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,12 @@ on: branches: [main, master] permissions: contents: read +# One run per ref: a newer push cancels the older run, so an older build can +# never reach the deploy job after a newer one. The deploy job's own `pages` +# group still serialises deployments. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: test: