mirror of https://github.com/theNewDynamic/gohugo-theme-ananke.git

Patrick Kollitsch
yesterday 59a245593e151401c8cbb268281ae262747dc0c3
chore(git): merge branch 'development'

* development:
build(vscode): update workspace configuration
test: scaffold Playwright + axe testing PoC (#1004)
feat: make section link above article title clickable and configurable
fix: generate responsive hero background images (#970)
feat: add since shortcode for release badges (#1018)
feat: copy-to-clipboard buttons for code blocks (#986)
fix: font size definition for in-paragraph code
fix: allow images in list summary cards (#971)
fix: make header height configurable (#972)
feat: show categories on posts (#973)
feat: add layout hooks head/body-start/end, header/main/content/footer-before/after
build(fix): proper discussion category for GH release
docs(ai): update issue prompt
8 files modified
15 files added
728 ■■■■■ changed files
.gitignore 3 ●●●●● patch | view | raw | blame | history
.markdownlintignore 2 ●●●●● patch | view | raw | blame | history
.vscode/custom-dictionary.txt 1 ●●●● patch | view | raw | blame | history
.vscode/extensions.json 5 ●●●●● patch | view | raw | blame | history
layouts/_partials/hooks/article/section-link.html 29 ●●●●● patch | view | raw | blame | history
layouts/_partials/site-scripts.html 10 ●●●●● patch | view | raw | blame | history
package-lock.json 153 ●●●● patch | view | raw | blame | history
package.json 14 ●●●● patch | view | raw | blame | history
playwright.config.ts 37 ●●●●● patch | view | raw | blame | history
tests/.gitignore 5 ●●●●● patch | view | raw | blame | history
tests/README.md 61 ●●●●● patch | view | raw | blame | history
tests/catalog.yaml 22 ●●●●● patch | view | raw | blame | history
tests/coverage-gate.mjs 103 ●●●●● patch | view | raw | blame | history
tests/e2e/accessibility.spec.ts 47 ●●●●● patch | view | raw | blame | history
tests/e2e/copy-button.spec.ts 51 ●●●●● patch | view | raw | blame | history
tests/fixtures/site/content/_index.md 5 ●●●●● patch | view | raw | blame | history
tests/fixtures/site/content/about.md 6 ●●●●● patch | view | raw | blame | history
tests/fixtures/site/content/posts/_index.md 6 ●●●●● patch | view | raw | blame | history
tests/fixtures/site/content/posts/hello.md 16 ●●●●● patch | view | raw | blame | history
tests/fixtures/site/content/posts/second.md 9 ●●●●● patch | view | raw | blame | history
tests/fixtures/site/hugo.toml 26 ●●●●● patch | view | raw | blame | history
tests/support/dev-server.mjs 62 ●●●●● patch | view | raw | blame | history
tests/support/prepare-site.mjs 55 ●●●●● patch | view | raw | blame | history
.gitignore
@@ -3,3 +3,6 @@
.hugo_build.lock
resources/_gen
todo*.md
# wireit
.wireit
.markdownlintignore
@@ -1 +1,3 @@
CHANGELOG.md
tests/.playwright/
tests/fixtures/site/public/
.vscode/custom-dictionary.txt
@@ -5,4 +5,5 @@
Kitchensink
licenselink
Philibert
submod
Warnf
.vscode/extensions.json
@@ -7,8 +7,9 @@
        "redhat.vscode-yaml",
        "streetsidesoftware.code-spell-checker-cspell-bundled-dictionaries",
        "budparr.language-hugo-vscode",
        "casualjim.gotemplate",
        "jinliming2.vscode-go-template",
        "gohugoio.gotmplfmt",
        "dunstontc.vscode-go-syntax",
        "jinliming2.vscode-go-template"
        "casualjim.gotemplate"
    ]
}
layouts/_partials/hooks/article/section-link.html
@@ -1,3 +1,30 @@
{{- /*
  Renders the section label above the article title, linked to a page within
  the section. The link target is controlled by the `ananke.section_link`
  parameter (page front matter, or site params for a site-wide default):
    - unset (default): the section index page (its `_index.md`)
    - "first":         the first page of the section, honouring its sort order
    - "<path>":        a specific page, resolved relative to the section
                       (e.g. "introduction" or "/about")
  The visible label is always the section title; only the destination changes.
*/ -}}
{{- with .CurrentSection -}}
  {{- $section := . -}}
  {{- $target := $section -}}
  {{- with $.Param "ananke.section_link" -}}
    {{- if eq . "first" -}}
      {{- with first 1 $section.RegularPages -}}
        {{- $target = index . 0 -}}
      {{- end -}}
    {{- else -}}
      {{- with $section.GetPage . -}}
        {{- $target = . -}}
      {{- end -}}
    {{- end -}}
  {{- end -}}
<aside class="instapaper_ignoref b helvetica tracked ttu">
    {{ .CurrentSection.Title }}
    <a class="link dim" href="{{ $target.Permalink }}">{{ $section.Title }}</a>
</aside>
{{- end -}}
layouts/_partials/site-scripts.html
@@ -14,11 +14,19 @@
  (function () {
    "use strict";
    // Reveal the copy buttons now that JavaScript is available.
    // Reveal the copy buttons once the DOM is ready. This script is included in
    // the <head>, so the code blocks may not exist yet when it first runs.
    function revealButtons() {
    var buttons = document.querySelectorAll(".code-block .code-copy[hidden]");
    for (var i = 0; i < buttons.length; i++) {
      buttons[i].hidden = false;
    }
    }
    if (document.readyState === "loading") {
      document.addEventListener("DOMContentLoaded", revealButtons);
    } else {
      revealButtons();
    }
    function flash(button) {
      var label = button.querySelector(".code-copy-label");
package-lock.json
@@ -15,10 +15,12 @@
                "tachyons": "4.12.0"
            },
            "devDependencies": {
        "@axe-core/playwright": "4.10.2",
                "@biomejs/biome": "2.4.16",
                "@github/markdownlint-github": "0.8.1",
                "@release-it/conventional-changelog": "11.0.1",
                "@types/node": "25.9.2",
        "@github/markdownlint-github": "0.8.0",
        "@playwright/test": "1.60.0",
        "@release-it/conventional-changelog": "11.0.0",
        "@types/node": "25.9.1",
                "dotenv": "17.4.2",
                "lint-staged": "17.0.7",
                "lockfile-lint": "5.0.0",
@@ -31,7 +33,21 @@
                "markdownlint-rule-title-case-style": "0.4.3",
                "release-it": "20.2.0",
                "simple-git-hooks": "2.13.1",
                "typescript": "6.0.3"
        "typescript": "6.0.3",
        "yaml": "2.7.0"
      }
    },
    "node_modules/@axe-core/playwright": {
      "version": "4.10.2",
      "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.10.2.tgz",
      "integrity": "sha512-6/b5BJjG6hDaRNtgzLIfKr5DfwyiLHO4+ByTLB0cJgWSM8Ll7KqtdblIS6bEkwSF642/Ex91vNqIl3GLXGlceg==",
      "dev": true,
      "license": "MPL-2.0",
      "dependencies": {
        "axe-core": "~4.10.3"
      },
      "peerDependencies": {
        "playwright-core": ">= 1.0.0"
            }
        },
        "node_modules/@babel/code-frame": {
@@ -128,6 +144,9 @@
                "arm64"
            ],
            "dev": true,
      "libc": [
        "glibc"
      ],
            "license": "MIT OR Apache-2.0",
            "optional": true,
            "os": [
@@ -145,6 +164,9 @@
                "arm64"
            ],
            "dev": true,
      "libc": [
        "musl"
      ],
            "license": "MIT OR Apache-2.0",
            "optional": true,
            "os": [
@@ -1638,14 +1660,13 @@
            }
        },
        "node_modules/@github/markdownlint-github": {
            "version": "0.8.1",
            "resolved": "https://registry.npmjs.org/@github/markdownlint-github/-/markdownlint-github-0.8.1.tgz",
            "integrity": "sha512-QnAlH3IdyQl1ibPESiKmnX1gC2d0de2pll8d2zGD8pR4nTL+yAIrXkq/kbkA0t3c6TVINsoEYo/4tQXwo5L7Pw==",
      "version": "0.8.0",
      "resolved": "https://registry.npmjs.org/@github/markdownlint-github/-/markdownlint-github-0.8.0.tgz",
      "integrity": "sha512-079sWT/2Z8EI5v02GTtSfvG06E1m8Q6xjYoQiGdPg6rSKVntpfBw6in79fGs+vc9cYihBHl73vkOoDcyH/Jl8g==",
            "dev": true,
            "license": "ISC",
            "dependencies": {
                "lodash-es": "^4.17.21",
                "markdown-it": "14.1.1"
        "lodash-es": "^4.17.15"
            },
            "engines": {
                "node": ">=18"
@@ -2214,10 +2235,26 @@
                "url": "https://github.com/phun-ky/typeof?sponsor=1"
            }
        },
    "node_modules/@playwright/test": {
      "version": "1.60.0",
      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
      "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "playwright": "1.60.0"
      },
      "bin": {
        "playwright": "cli.js"
      },
      "engines": {
        "node": ">=18"
      }
    },
        "node_modules/@release-it/conventional-changelog": {
            "version": "11.0.1",
            "resolved": "https://registry.npmjs.org/@release-it/conventional-changelog/-/conventional-changelog-11.0.1.tgz",
            "integrity": "sha512-SHAnHfOFhazpDeYuQSqH8Qm8RJa2oREn2ILSod+9s8dqiA18mBgpPyYlpvRaqISCVerSmLzEZghQBKphkrf4IQ==",
      "version": "11.0.0",
      "resolved": "https://registry.npmjs.org/@release-it/conventional-changelog/-/conventional-changelog-11.0.0.tgz",
      "integrity": "sha512-3/WyzObY+y+EejBt+J2vcojFPLUiGu14liRiaPWc0bNVluhRIsADeJ785Iq5ynnhdd1zcjMNh5lBmM/J6/KXig==",
            "dev": true,
            "license": "MIT",
            "dependencies": {
@@ -2316,9 +2353,9 @@
            "license": "MIT"
        },
        "node_modules/@types/node": {
            "version": "25.9.2",
            "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
            "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
      "version": "25.9.1",
      "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
      "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
            "dev": true,
            "license": "MIT",
            "dependencies": {
@@ -2509,6 +2546,16 @@
                "postcss": "^8.1.0"
            }
        },
    "node_modules/axe-core": {
      "version": "4.10.3",
      "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz",
      "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==",
      "dev": true,
      "license": "MPL-2.0",
      "engines": {
        "node": ">=4"
      }
    },
        "node_modules/baseline-browser-mapping": {
            "version": "2.10.21",
            "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz",
@@ -3907,6 +3954,21 @@
                "url": "https://github.com/sponsors/rawify"
            }
        },
    "node_modules/fsevents": {
      "version": "2.3.2",
      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
      "dev": true,
      "hasInstallScript": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
      }
    },
        "node_modules/get-caller-file": {
            "version": "2.0.5",
            "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -4528,6 +4590,23 @@
                "yaml": "^2.9.0"
            }
        },
    "node_modules/lint-staged/node_modules/yaml": {
      "version": "2.9.0",
      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
      "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
      "dev": true,
      "license": "ISC",
      "optional": true,
      "bin": {
        "yaml": "bin.mjs"
      },
      "engines": {
        "node": ">= 14.6"
      },
      "funding": {
        "url": "https://github.com/sponsors/eemeli"
      }
    },
        "node_modules/listr2": {
            "version": "10.2.1",
            "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
@@ -6066,6 +6145,38 @@
                "pathe": "^2.0.3"
            }
        },
    "node_modules/playwright": {
      "version": "1.60.0",
      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
      "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "playwright-core": "1.60.0"
      },
      "bin": {
        "playwright": "cli.js"
      },
      "engines": {
        "node": ">=18"
      },
      "optionalDependencies": {
        "fsevents": "2.3.2"
      }
    },
    "node_modules/playwright-core": {
      "version": "1.60.0",
      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
      "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
      "dev": true,
      "license": "Apache-2.0",
      "bin": {
        "playwright-core": "cli.js"
      },
      "engines": {
        "node": ">=18"
      }
    },
        "node_modules/postcss": {
            "version": "8.5.15",
            "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -8151,20 +8262,16 @@
            }
        },
        "node_modules/yaml": {
            "version": "2.9.0",
            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
            "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
      "version": "2.7.0",
      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz",
      "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==",
            "dev": true,
            "license": "ISC",
            "optional": true,
            "bin": {
                "yaml": "bin.mjs"
            },
            "engines": {
                "node": ">= 14.6"
            },
            "funding": {
                "url": "https://github.com/sponsors/eemeli"
        "node": ">= 14"
            }
        },
        "node_modules/yargs": {
package.json
@@ -31,10 +31,12 @@
        "tachyons": "4.12.0"
    },
    "devDependencies": {
    "@axe-core/playwright": "4.10.2",
        "@biomejs/biome": "2.4.16",
        "@github/markdownlint-github": "0.8.1",
        "@release-it/conventional-changelog": "11.0.1",
        "@types/node": "25.9.2",
    "@github/markdownlint-github": "0.8.0",
    "@playwright/test": "1.60.0",
    "@release-it/conventional-changelog": "11.0.0",
    "@types/node": "25.9.1",
        "dotenv": "17.4.2",
        "lint-staged": "17.0.7",
        "lockfile-lint": "5.0.0",
@@ -47,7 +49,8 @@
        "markdownlint-rule-title-case-style": "0.4.3",
        "release-it": "20.2.0",
        "simple-git-hooks": "2.13.1",
        "typescript": "6.0.3"
    "typescript": "6.0.3",
    "yaml": "2.7.0"
    },
    "scripts": {
        "hook:commit": "lint-staged --config .lintstagedrc.js",
@@ -61,6 +64,9 @@
        "release:pre": "release-it --preRelease=prerelease --config .release-it.ts",
        "server": "hugo server --environment documentation",
        "test": "node scripts/test-hugo-quickstart.ts",
    "test:coverage-gate": "node tests/coverage-gate.mjs",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
        "test:quickstart": "node scripts/test-hugo-quickstart.ts",
        "test:quickstart:submodule": "node scripts/test-hugo-quickstart.ts --use-submodule",
        "update:docs": "git add docs/ && (git diff --cached --quiet || git commit -m \"chore(git): update documentation submodule\")"
playwright.config.ts
New file
@@ -0,0 +1,37 @@
import { defineConfig, devices } from "@playwright/test";
const PORT = Number(process.env["ANANKE_TEST_PORT"] || 4321);
const baseURL = `http://localhost:${PORT}`;
const isCI = !!process.env["CI"];
/**
 * Playwright configuration for the Ananke theme.
 *
 * The `webServer` builds the test fixture site against the local theme working
 * tree and serves it statically, so the suite always runs against the current
 * branch. See tests/support/dev-server.mjs.
 */
export default defineConfig({
    testDir: "./tests/e2e",
    outputDir: "./tests/.playwright/results",
    fullyParallel: true,
    forbidOnly: isCI,
    retries: isCI ? 2 : 0,
    reporter: isCI ? [["github"], ["list"]] : "list",
    use: {
        baseURL,
        trace: "on-first-retry",
    },
    projects: [
        {
            name: "chromium",
            use: { ...devices["Desktop Chrome"] },
        },
    ],
    webServer: {
        command: "node tests/support/dev-server.mjs",
        url: baseURL,
        reuseExistingServer: !isCI,
        timeout: 120_000,
    },
});
tests/.gitignore
New file
@@ -0,0 +1,5 @@
# Built fixture site and Playwright artifacts
fixtures/site/public/
fixtures/site/resources/
fixtures/site/.hugo_build.lock
.playwright/
tests/README.md
New file
@@ -0,0 +1,61 @@
# Tests
This directory holds the theme's automated tests. It is the proof-of-concept
for the testing system tracked in the testing epic.
## Layout
```text
tests/
├── fixtures/site/      # a minimal Hugo site that uses the theme
├── e2e/                # Playwright specs (E2E, accessibility, no-JS)
├── support/            # build-the-fixture + static-server helpers
├── catalog.yaml        # feature -> test manifest for the coverage gate
└── coverage-gate.mjs   # fails CI when a param/shortcode has no test
```
The legacy build/HTML harness still lives at
`scripts/test-hugo-quickstart.ts` and is being migrated into this structure.
## Running
```bash
# Browser end-to-end, accessibility, and progressive-enhancement (no-JS) tests.
# Builds the fixture site against the local theme working tree, serves it, and
# runs Playwright. Requires browsers: `npx playwright install chromium`.
npm run test:e2e
# Feature -> test coverage gate: fails if an [ananke] param or a shortcode has
# no entry in tests/catalog.yaml.
npm run test:coverage-gate
# Legacy quickstart build/assertion harness.
npm test
```
## How the fixture is built
`tests/support/prepare-site.mjs` creates a temporary themes directory with a
symlink to the repository root and runs Hugo against `tests/fixtures/site`, so
the suite always exercises the **current** theme code (including uncommitted
changes) without committing an absolute-path symlink. The built site is served
by `tests/support/dev-server.mjs`, which Playwright launches via
`webServer` in `playwright.config.ts`.
## Adding a test for a feature
When you add or change a feature:
1. If it has a config parameter or shortcode, add fixture content that
   exercises it under `tests/fixtures/site/`.
2. Add a spec under `tests/e2e/` (interaction, output, or accessibility).
3. Register it in `tests/catalog.yaml` so the coverage gate is satisfied.
The coverage gate (`npm run test:coverage-gate`) fails if a new `[ananke]`
parameter or shortcode is not listed in the catalog with at least one test, so
tests stay in sync with features.
## Accessibility note
The axe suite catches roughly 30-40% of WCAG issues. It is a floor, not a
guarantee — pair it with manual keyboard and screen-reader checks.
tests/catalog.yaml
New file
@@ -0,0 +1,22 @@
# Feature -> test catalog (coverage gate).
#
# Every [ananke] parameter and every shortcode in layouts/_shortcodes must be
# listed here with at least one test reference, or `npm run test:coverage-gate`
# fails. This forces a test to accompany each new or changed feature.
#
# `tests` entries are free-form references (spec files or fixture folders) for
# humans; the gate only checks that the key is present and has a non-empty list.
params:
  ananke.show_recent_posts:
    tests: ["scripts/test-hugo-quickstart.ts"]
  ananke.show_categories:
    tests: ["scripts/test-hugo-quickstart.ts"]
  ananke.copy_code:
    tests: ["tests/e2e/copy-button.spec.ts"]
shortcodes:
  form-contact:
    tests: ["scripts/test-hugo-quickstart.ts"] # TODO: add a dedicated form-contact test
  page-index:
    tests: ["scripts/test-hugo-quickstart.ts"] # TODO: add a dedicated page-index test
tests/coverage-gate.mjs
New file
@@ -0,0 +1,103 @@
#!/usr/bin/env node
/**
 * Feature -> test coverage gate.
 *
 * Fails when a user-facing feature has no test catalogued:
 *   - every direct key under the [ananke] table in config/_default/params.toml
 *   - every shortcode in layouts/_shortcodes/
 * must appear in tests/catalog.yaml with a non-empty `tests` list.
 *
 * It also reports catalog entries that no longer match a real param/shortcode
 * (stale entries), so the catalog stays in sync as features are added, renamed,
 * or removed.
 *
 * Scope note: only the direct [ananke] toggles are gated here (not the deep
 * [ananke.social.networks.*] catalog). Widen `collectAnankeParams` as the gate
 * matures.
 */
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parse } from "yaml";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "..");
/** Direct `key = value` entries under the top-level [ananke] table. */
function collectAnankeParams() {
    const toml = readFileSync(
        join(repoRoot, "config", "_default", "params.toml"),
        "utf8",
    );
    const keys = new Set();
    let inAnanke = false;
    for (const raw of toml.split("\n")) {
        const line = raw.trim();
        if (line.startsWith("[")) {
            inAnanke = line === "[ananke]";
            continue;
        }
        if (!inAnanke || line === "" || line.startsWith("#")) continue;
        const eq = line.indexOf("=");
        if (eq > 0) keys.add(`ananke.${line.slice(0, eq).trim()}`);
    }
    return keys;
}
/** Shortcode names from layouts/_shortcodes/*.html. */
function collectShortcodes() {
    const dir = join(repoRoot, "layouts", "_shortcodes");
    return new Set(
        readdirSync(dir)
            .filter((f) => f.endsWith(".html"))
            .map((f) => f.replace(/\.html$/, "")),
    );
}
function catalogued(section) {
    const out = new Set();
    for (const [name, value] of Object.entries(section || {})) {
        if (value && Array.isArray(value.tests) && value.tests.length > 0) {
            out.add(name);
        }
    }
    return out;
}
const catalog = parse(readFileSync(join(here, "catalog.yaml"), "utf8")) || {};
const params = collectAnankeParams();
const shortcodes = collectShortcodes();
const catParams = catalogued(catalog.params);
const catShortcodes = catalogued(catalog.shortcodes);
const errors = [];
for (const p of params) {
    if (!catParams.has(p))
        errors.push(`param '${p}' has no test in tests/catalog.yaml`);
}
for (const s of shortcodes) {
    if (!catShortcodes.has(s))
        errors.push(`shortcode '${s}' has no test in tests/catalog.yaml`);
}
// Stale catalog entries (kept as warnings so renames are noticed promptly).
const warnings = [];
for (const p of Object.keys(catalog.params || {})) {
    if (!params.has(p)) warnings.push(`catalog param '${p}' no longer exists`);
}
for (const s of Object.keys(catalog.shortcodes || {})) {
    if (!shortcodes.has(s))
        warnings.push(`catalog shortcode '${s}' no longer exists`);
}
for (const w of warnings) console.warn(`warning: ${w}`);
if (errors.length > 0) {
    console.error("\nCoverage gate failed:");
    for (const e of errors) console.error(`  - ${e}`);
    console.error(
        "\nAdd the feature and at least one test reference to tests/catalog.yaml.",
    );
    process.exit(1);
}
console.log(
    `Coverage gate passed: ${params.size} ananke param(s), ${shortcodes.size} shortcode(s) all have tests.`,
);
tests/e2e/accessibility.spec.ts
New file
@@ -0,0 +1,47 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
/**
 * Automated WCAG 2.2 AA accessibility audits across the theme's page types.
 *
 * Note: automated tools catch roughly 30-40% of WCAG issues, so this suite is a
 * floor, not a guarantee — it is paired with a manual checklist (see the
 * accessibility sub-issue under the testing epic).
 */
const PAGES: Record<string, string> = {
    homepage: "/",
    "section list": "/posts/",
    "single page": "/posts/hello/",
    "standalone page": "/about/",
    "taxonomy terms": "/tags/",
    "taxonomy term": "/tags/alpha/",
    "404": "/404.html",
};
const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
/**
 * Page types with a known, tracked WCAG AA contrast failure (Tachyons `gray`
 * #777 on summary cards and single-page bylines). Marked as expected failures
 * so the suite is green while the bug is open; when #1015 is fixed these will
 * start passing and Playwright will flag the stale annotation for removal.
 */
const KNOWN_FAILURES = new Set([
    "section list",
    "single page",
    "taxonomy terms",
    "taxonomy term",
]);
for (const [name, path] of Object.entries(PAGES)) {
    test(`${name} has no automatically detectable a11y violations`, async ({
        page,
    }) => {
        if (KNOWN_FAILURES.has(name)) {
            test.fail(true, "Known WCAG AA contrast issue, tracked in #1015");
        }
        await page.goto(path);
        const results = await new AxeBuilder({ page }).withTags(TAGS).analyze();
        expect(results.violations).toEqual([]);
    });
}
tests/e2e/copy-button.spec.ts
New file
@@ -0,0 +1,51 @@
import { expect, test } from "@playwright/test";
const POST = "/posts/hello/";
test.describe("copy-to-clipboard code button (#986)", () => {
    test("wraps fenced code blocks but not inline code", async ({ page }) => {
        await page.goto(POST);
        // Exactly one fenced block on this page → one .code-block wrapper.
        await expect(page.locator(".code-block")).toHaveCount(1);
        // Inline `code` lives outside a .code-block wrapper.
        await expect(page.locator(".code-block code")).toHaveCount(1);
    });
    test("reveals the button when JavaScript runs", async ({ page }) => {
        await page.goto(POST);
        const button = page.locator(".code-block .code-copy");
        await expect(button).toBeVisible();
        await expect(button).toHaveAttribute("aria-label", /copy/i);
    });
    test("copies the code and shows feedback", async ({ page, context }) => {
        await context.grantPermissions(["clipboard-read", "clipboard-write"]);
        await page.goto(POST);
        const button = page.locator(".code-block .code-copy");
        await button.click();
        // Feedback state.
        await expect(button).toHaveClass(/is-copied/);
        await expect(button.locator(".code-copy-label")).toHaveText("Copied");
        // The clipboard actually holds the code (localhost is a secure context).
        const clipboard = await page.evaluate(() => navigator.clipboard.readText());
        expect(clipboard).toContain("copy_code = true");
    });
});
test.describe("without JavaScript (progressive enhancement)", () => {
    test.use({ javaScriptEnabled: false });
    test("shows no copy button but keeps the code", async ({ page }) => {
        await page.goto(POST);
        // The button is rendered with the `hidden` attribute and never revealed,
        // so it is not visible to the user.
        await expect(page.locator(".code-block .code-copy")).toBeHidden();
        // The code itself is still present and selectable.
        await expect(page.locator(".code-block pre")).toContainText(
            "copy_code = true",
        );
    });
});
tests/fixtures/site/content/_index.md
New file
@@ -0,0 +1,5 @@
---
title: "Ananke Test Fixture"
---
Homepage introduction for the test fixture site. Recent posts appear below.
tests/fixtures/site/content/about.md
New file
@@ -0,0 +1,6 @@
---
title: "About"
description: "About the test fixture site."
---
A standalone page used by the accessibility tests.
tests/fixtures/site/content/posts/_index.md
New file
@@ -0,0 +1,6 @@
---
title: "Posts"
description: "All posts on the test fixture site."
---
The posts section, used to exercise list-page rendering.
tests/fixtures/site/content/posts/hello.md
New file
@@ -0,0 +1,16 @@
---
title: "Hello World"
date: 2026-01-01
description: "A post with a fenced code block for the copy-button tests."
categories: ["News"]
tags: ["alpha", "beta"]
---
A post body with a fenced code block, used by the copy-button E2E tests.
```toml
[params.ananke]
copy_code = true
```
Inline `code` must not receive a copy button.
tests/fixtures/site/content/posts/second.md
New file
@@ -0,0 +1,9 @@
---
title: "Second Post"
date: 2026-01-02
description: "A second post so list and taxonomy pages have more than one entry."
categories: ["Tech"]
tags: ["alpha"]
---
A second post so the homepage list and taxonomy pages have multiple entries.
tests/fixtures/site/hugo.toml
New file
@@ -0,0 +1,26 @@
baseURL = "http://localhost:4321/"
title = "Ananke Test Fixture"
theme = "ananke"
enableRobotsTXT = true
[taxonomies]
category = "categories"
tag = "tags"
[params]
mainSections = ["posts"]
[params.ananke]
show_recent_posts = true
show_categories = true
copy_code = true
[[menus.main]]
name = "Posts"
pageRef = "/posts/"
weight = 10
[[menus.main]]
name = "About"
pageRef = "/about/"
weight = 20
tests/support/dev-server.mjs
New file
@@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
 * Build the fixture site, then serve it as a static site for Playwright.
 *
 * Used as the Playwright `webServer.command`: it builds first (so tests always
 * run against the current theme), then serves `tests/fixtures/site/public`
 * until the process is killed.
 */
import { createReadStream, existsSync, statSync } from "node:fs";
import { createServer } from "node:http";
import { extname, join, normalize } from "node:path";
import { buildFixtureSite } from "./prepare-site.mjs";
const PORT = Number(process.env.ANANKE_TEST_PORT || 4321);
const TYPES = {
    ".html": "text/html; charset=utf-8",
    ".css": "text/css; charset=utf-8",
    ".js": "text/javascript; charset=utf-8",
    ".json": "application/json; charset=utf-8",
    ".svg": "image/svg+xml",
    ".xml": "application/xml; charset=utf-8",
    ".txt": "text/plain; charset=utf-8",
    ".woff2": "font/woff2",
};
const root = buildFixtureSite();
function resolvePath(urlPath) {
    const clean = normalize(decodeURIComponent(urlPath.split("?")[0])).replace(
        /^(\.\.[/\\])+/,
        "",
    );
    let filePath = join(root, clean);
    if (existsSync(filePath) && statSync(filePath).isDirectory()) {
        filePath = join(filePath, "index.html");
    }
    return filePath;
}
const server = createServer((req, res) => {
    let filePath = resolvePath(req.url || "/");
    if (!existsSync(filePath)) {
        // Serve Hugo's generated 404 page so /404.html and unknown routes work.
        filePath = join(root, "404.html");
        if (!existsSync(filePath)) {
            res.statusCode = 404;
            res.end("Not found");
            return;
        }
        res.statusCode = req.url === "/404.html" ? 200 : 404;
    }
    res.setHeader(
        "Content-Type",
        TYPES[extname(filePath)] || "application/octet-stream",
    );
    createReadStream(filePath).pipe(res);
});
server.listen(PORT, () => {
    console.log(`Fixture site served at http://localhost:${PORT}/`);
});
tests/support/prepare-site.mjs
New file
@@ -0,0 +1,55 @@
#!/usr/bin/env node
/**
 * Build the test fixture site against the *local* theme working tree.
 *
 * Hugo resolves a classic theme from `<themesDir>/<themeName>`. We create a
 * temporary themes directory containing a symlink `ananke -> <repo root>` so
 * the build exercises the current branch (including uncommitted changes)
 * without committing an absolute-path symlink. Output goes to
 * `tests/fixtures/site/public`, which the static server then serves.
 */
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "..", "..");
const siteDir = resolve(here, "..", "fixtures", "site");
const publicDir = join(siteDir, "public");
export function buildFixtureSite() {
    const themesDir = mkdtempSync(join(tmpdir(), "ananke-test-themes-"));
    try {
        symlinkSync(repoRoot, join(themesDir, "ananke"), "dir");
        rmSync(publicDir, { recursive: true, force: true });
        const result = spawnSync(
            "hugo",
            [
                "--source",
                siteDir,
                "--themesDir",
                themesDir,
                "--destination",
                publicDir,
                "--environment",
                "production",
                "--logLevel",
                "warn",
            ],
            { stdio: "inherit" },
        );
        if (result.status !== 0) {
            throw new Error(`hugo build failed with code ${result.status}`);
        }
    } finally {
        rmSync(themesDir, { recursive: true, force: true });
    }
    return publicDir;
}
// Allow running directly: `node tests/support/prepare-site.mjs`
if (import.meta.url === `file://${process.argv[1]}`) {
    buildFixtureSite();
}