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

Patrick Kollitsch
yesterday 1832a681026ac3db6e62aa90cb5ed75886ea69ac
build(deps): onboard dnbhq configurations and linting fixes
21 files modified
3 files deleted
5 files added
15556 ■■■■■ changed files
.github/dependabot.yml 64 ●●●●● patch | view | raw | blame | history
.github/rulesets/protect-development.json 78 ●●●●● patch | view | raw | blame | history
.github/rulesets/protect-main.json 72 ●●●●● patch | view | raw | blame | history
.github/scripts/posterboy.mjs 63 ●●●●● patch | view | raw | blame | history
.lintstagedrc.js 28 ●●●●● patch | view | raw | blame | history
.markdownlint.jsonc 133 ●●●●● patch | view | raw | blame | history
.release-it.ts 206 ●●●●● patch | view | raw | blame | history
.vscode/instructions/shortcodes.instructions.md 17 ●●●●● patch | view | raw | blame | history
.vscode/prompts/test-hugo-versions.prompt.md 300 ●●●●● patch | view | raw | blame | history
.vscode/settings.json 66 ●●●●● patch | view | raw | blame | history
ananke.code-workspace 102 ●●●●● patch | view | raw | blame | history
assets/ananke/css/_code.css 9 ●●●●● patch | view | raw | blame | history
assets/ananke/css/_hugo-internal-templates.css 8 ●●●●● patch | view | raw | blame | history
assets/ananke/css/_social-icons.css 6 ●●●●● patch | view | raw | blame | history
assets/ananke/css/_styles.css 5 ●●●●● patch | view | raw | blame | history
biome.json 11 ●●●●● patch | view | raw | blame | history
lint-staged.config.ts 22 ●●●●● patch | view | raw | blame | history
package-lock.json 10682 ●●●●● patch | view | raw | blame | history
package.json 177 ●●●●● patch | view | raw | blame | history
playwright.config.ts 44 ●●●●● patch | view | raw | blame | history
renovate.json5 7 ●●●●● patch | view | raw | blame | history
scripts/synch-social-icons.ts 1238 ●●●●● patch | view | raw | blame | history
scripts/test-hugo-quickstart.ts 1854 ●●●●● patch | view | raw | blame | history
tests/coverage-gate.mjs 88 ●●●●● patch | view | raw | blame | history
tests/e2e/accessibility.spec.ts 42 ●●●●● patch | view | raw | blame | history
tests/e2e/copy-button.spec.ts 72 ●●●●● patch | view | raw | blame | history
tests/support/dev-server.mjs 68 ●●●●● patch | view | raw | blame | history
tests/support/prepare-site.mjs 56 ●●●●● patch | view | raw | blame | history
tsconfig.json 38 ●●●●● patch | view | raw | blame | history
.github/dependabot.yml
File was deleted
.github/rulesets/protect-development.json
@@ -1,41 +1,41 @@
{
    "id": 15502619,
    "name": "protect-development",
    "target": "branch",
    "source_type": "Repository",
    "source": "gohugo-ananke/ananke",
    "enforcement": "active",
    "conditions": {
        "ref_name": {
            "exclude": [],
            "include": ["refs/heads/development"]
        }
    },
    "rules": [
        {
            "type": "deletion"
        },
        {
            "type": "non_fast_forward"
        },
        {
            "type": "pull_request",
            "parameters": {
                "required_approving_review_count": 0,
                "dismiss_stale_reviews_on_push": true,
                "required_reviewers": [],
                "require_code_owner_review": false,
                "require_last_push_approval": false,
                "required_review_thread_resolution": true,
                "allowed_merge_methods": ["squash"]
            }
        }
    ],
    "bypass_actors": [
        {
            "actor_id": 17174680,
            "actor_type": "Team",
            "bypass_mode": "always"
        }
    ]
  "id": 15502619,
  "name": "protect-development",
  "target": "branch",
  "source_type": "Repository",
  "source": "gohugo-ananke/ananke",
  "enforcement": "active",
  "conditions": {
    "ref_name": {
      "exclude": [],
      "include": ["refs/heads/development"]
    }
  },
  "rules": [
    {
      "type": "deletion"
    },
    {
      "type": "non_fast_forward"
    },
    {
      "type": "pull_request",
      "parameters": {
        "required_approving_review_count": 0,
        "dismiss_stale_reviews_on_push": true,
        "required_reviewers": [],
        "require_code_owner_review": false,
        "require_last_push_approval": false,
        "required_review_thread_resolution": true,
        "allowed_merge_methods": ["squash"]
      }
    }
  ],
  "bypass_actors": [
    {
      "actor_id": 17174680,
      "actor_type": "Team",
      "bypass_mode": "always"
    }
  ]
}
.github/rulesets/protect-main.json
@@ -1,38 +1,38 @@
{
    "id": 15502478,
    "name": "protect-main",
    "target": "branch",
    "source_type": "Repository",
    "source": "gohugo-ananke/ananke",
    "enforcement": "active",
    "conditions": {
        "ref_name": {
            "exclude": [],
            "include": ["refs/heads/main"]
        }
    },
    "rules": [
        {
            "type": "deletion"
        },
        {
            "type": "non_fast_forward"
        },
        {
            "type": "pull_request",
            "parameters": {
                "required_approving_review_count": 0,
                "dismiss_stale_reviews_on_push": false,
                "required_reviewers": [],
                "require_code_owner_review": false,
                "require_last_push_approval": false,
                "required_review_thread_resolution": false,
                "allowed_merge_methods": ["merge", "squash", "rebase"]
            }
        },
        {
            "type": "required_linear_history"
        }
    ],
    "bypass_actors": []
  "id": 15502478,
  "name": "protect-main",
  "target": "branch",
  "source_type": "Repository",
  "source": "gohugo-ananke/ananke",
  "enforcement": "active",
  "conditions": {
    "ref_name": {
      "exclude": [],
      "include": ["refs/heads/main"]
    }
  },
  "rules": [
    {
      "type": "deletion"
    },
    {
      "type": "non_fast_forward"
    },
    {
      "type": "pull_request",
      "parameters": {
        "required_approving_review_count": 0,
        "dismiss_stale_reviews_on_push": false,
        "required_reviewers": [],
        "require_code_owner_review": false,
        "require_last_push_approval": false,
        "required_review_thread_resolution": false,
        "allowed_merge_methods": ["merge", "squash", "rebase"]
      }
    },
    {
      "type": "required_linear_history"
    }
  ],
  "bypass_actors": []
}
.github/scripts/posterboy.mjs
@@ -1,14 +1,14 @@
import { readFile, writeFile, access, mkdir } from 'node:fs/promises';
import path from 'node:path';
import fs from 'node:fs';
import dotenv from 'dotenv';
import { homedir } from 'node:os';
import fs from "node:fs";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import dotenv from "dotenv";
const userHomeDir = homedir();
// Load .env files
const GLOBAL_ENV_PATH = path.join(userHomeDir, '.env');
const LOCAL_ENV_PATH = path.resolve('.env');
const GLOBAL_ENV_PATH = path.join(userHomeDir, ".env");
const LOCAL_ENV_PATH = path.resolve(".env");
/**
 * Load environment variables from a file if it exists.
@@ -28,13 +28,14 @@
process.env = { ...globalEnv, ...process.env, ...localEnv };
// Configurable values
const DISCORD_WEBHOOK = process.env.DISCORD_WEBHOOK || '';
const GITHUB_DEV_TOKEN = process.env.GITHUB_DEV_TOKEN || '';
const GITHUB_REPO = process.env.GITHUB_REPO || 'gohugo-ananke/ananke';
const DEFAULT_MESSAGE_TEMPLATE = 'New release: {{tag_name}} - {{html_url}}';
const MESSAGE_TEMPLATE = process.env.MESSAGE_TEMPLATE || DEFAULT_MESSAGE_TEMPLATE;
const CACHE_DIR = './cache';
const CACHE_FILE = 'github-releases.json';
const DISCORD_WEBHOOK = process.env.DISCORD_WEBHOOK || "";
const GITHUB_DEV_TOKEN = process.env.GITHUB_DEV_TOKEN || "";
const GITHUB_REPO = process.env.GITHUB_REPO || "gohugo-ananke/ananke";
const DEFAULT_MESSAGE_TEMPLATE = "New release: {{tag_name}} - {{html_url}}";
const MESSAGE_TEMPLATE =
  process.env.MESSAGE_TEMPLATE || DEFAULT_MESSAGE_TEMPLATE;
const CACHE_DIR = "./cache";
const CACHE_FILE = "github-releases.json";
const CACHE_FILE_PATH = path.join(CACHE_DIR, CACHE_FILE);
/**
@@ -60,7 +61,7 @@
 */
async function readCache() {
  try {
    const data = await readFile(CACHE_FILE_PATH, 'utf8');
    const data = await readFile(CACHE_FILE_PATH, "utf8");
    return JSON.parse(data) || [];
  } catch {
    return [];
@@ -81,11 +82,14 @@
 */
async function fetchLatestRelease() {
  try {
    const response = await fetch('https://api.github.com/repos/' + GITHUB_REPO + '/releases', {
      headers: {
        Authorization: `token ${GITHUB_DEV_TOKEN}`,
    const response = await fetch(
      "https://api.github.com/repos/" + GITHUB_REPO + "/releases",
      {
        headers: {
          Authorization: `token ${GITHUB_DEV_TOKEN}`,
        },
      },
    });
    );
    if (!response.ok) {
      throw new Error(`GitHub API request failed: ${response.statusText}`);
@@ -93,13 +97,13 @@
    const releases = await response.json();
    if (!Array.isArray(releases) || releases.length === 0) {
      console.log('No releases found.');
      console.log("No releases found.");
      return null;
    }
    return releases[0];
  } catch (err) {
    console.error('Failed to fetch releases:', err.message);
    console.error("Failed to fetch releases:", err.message);
    return null;
  }
}
@@ -111,8 +115,8 @@
async function postToDiscord(message) {
  try {
    const response = await fetch(DISCORD_WEBHOOK, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ content: message }),
    });
@@ -120,9 +124,9 @@
      throw new Error(`Failed to post to Discord: ${response.statusText}`);
    }
    console.log('Posted to Discord successfully.');
    console.log("Posted to Discord successfully.");
  } catch (err) {
    console.error('Failed to post to Discord:', err.message);
    console.error("Failed to post to Discord:", err.message);
  }
}
@@ -132,7 +136,10 @@
 * @returns {string}
 */
function formatMessage(releaseData) {
  return MESSAGE_TEMPLATE.replace(/{{\s*(\w+)\s*}}/g, (_, key) => releaseData[key] || '');
  return MESSAGE_TEMPLATE.replace(
    /{{\s*(\w+)\s*}}/g,
    (_, key) => releaseData[key] || "",
  );
}
/**
@@ -152,10 +159,10 @@
      cachedIds.push(latestRelease.tag_name);
      await writeCache(cachedIds);
    } else {
      console.log('No new releases to post.');
      console.log("No new releases to post.");
    }
  } catch (err) {
    console.error('Error:', err.message);
    console.error("Error:", err.message);
  }
}
.lintstagedrc.js
File was deleted
.markdownlint.jsonc
File was deleted
.release-it.ts
@@ -1,154 +1,92 @@
import { execSync } from "node:child_process";
import { createReleaseConfig } from "@dnbhq/release-config";
import type { Config } from "release-it";
interface ConventionalCommitLike {
    type?: string;
    notes?: unknown[];
}
function getCurrentBranch(): string {
    try {
        return execSync("git rev-parse --abbrev-ref HEAD", {
            encoding: "utf8",
            stdio: ["ignore", "pipe", "pipe"],
        }).trim();
    } catch (error: unknown) {
        console.error("Failed to determine the current Git branch.");
        console.error(error);
        process.exit(1);
    }
  try {
    return execSync("git rev-parse --abbrev-ref HEAD", {
      encoding: "utf8",
      stdio: ["ignore", "pipe", "pipe"],
    }).trim();
  } catch (error: unknown) {
    console.error("Failed to determine the current Git branch.");
    console.error(error);
    process.exit(1);
  }
}
function isPreReleaseRun(argv: string[]): boolean {
    return argv.some((argument) => {
        return (
            argument === "--preRelease" ||
            argument.startsWith("--preRelease=") ||
            argument === "--preReleaseId" ||
            argument.startsWith("--preReleaseId=")
        );
    });
  return argv.some((argument) => {
    return (
      argument === "--preRelease" ||
      argument.startsWith("--preRelease=") ||
      argument === "--preReleaseId" ||
      argument.startsWith("--preReleaseId=")
    );
  });
}
const currentBranch = getCurrentBranch();
const preReleaseRun = isPreReleaseRun(process.argv);
if (preReleaseRun && currentBranch !== "development") {
    console.error(
        `Pre-releases are only allowed on "development". Current branch: "${currentBranch}".`,
    );
    process.exit(1);
  console.error(
    `Pre-releases are only allowed on "development". Current branch: "${currentBranch}".`,
  );
  process.exit(1);
}
if (!preReleaseRun && currentBranch !== "main") {
    console.error(
        `Stable releases are only allowed on "main". Current branch: "${currentBranch}".`,
    );
    process.exit(1);
  console.error(
    `Stable releases are only allowed on "main". Current branch: "${currentBranch}".`,
  );
  process.exit(1);
}
const config = {
    npm: {
        publish: false,
    },
    git: {
        requireCleanWorkingDir: true,
        requireBranch: currentBranch,
        commit: true,
        commitMessage: "chore(release): v${version}",
        commitArgs: ["--no-verify"],
        tag: true,
        tagName: "v${version}",
        push: true,
        pushArgs: ["--follow-tags"],
    },
    github: {
        release: true,
        releaseName: "Release v${version}",
        skipChecks: true,
        tokenRef: "GITHUB_ANANKE_TOKEN_ADMIN_PRIVATE",
        comments: {
            submit: true,
        },
        discussionCategoryName: "1-release-notes",
    },
    plugins: {
        "@release-it/conventional-changelog": {
            infile: "CHANGELOG.md",
            preset: {
                name: "conventionalcommits",
                commitUrlFormat:
                    "https://github.com/gohugo-ananke/ananke/commit/{{hash}}",
                compareUrlFormat:
                    "https://github.com/gohugo-ananke/ananke/compare/{{previousTag}}...{{currentTag}}",
                types: [
                    { type: "feat", section: "Features" },
                    { type: "fix", section: "Bug Fixes" },
                    { type: "build", section: "Build" },
                    { type: "chore", section: "Chores" },
                    { type: "ci", section: "CI" },
                    { type: "docs", section: "Documentation" },
                    { type: "perf", section: "Performance" },
                    { type: "refactor", section: "Refactoring" },
                    { type: "revert", section: "Reverts" },
                    { type: "style", section: "Styles" },
                    { type: "test", section: "Tests" },
                    { type: "ai", section: "AI Instruction Files" },
                ],
            },
            whatBump(commits: ConventionalCommitLike[]) {
                let level: 2 | 1 | 0 | null = null;
                for (const commit of commits) {
                    const notes = Array.isArray(commit.notes) ? commit.notes : [];
                    const type = typeof commit.type === "string" ? commit.type : "";
                    if (notes.length > 0) {
                        return {
                            level: 0,
                            reason: "There are BREAKING CHANGES.",
                        };
                    }
                    if (type === "feat") {
                        level = 1;
                        continue;
                    }
                    if (
                        level === null &&
                        [
                            "fix",
                            "build",
                            "chore",
                            "ci",
                            "docs",
                            "perf",
                            "refactor",
                            "revert",
                            "style",
                            "test",
                            "ai",
                        ].includes(type)
                    ) {
                        level = 2;
                    }
                }
                if (level === null) {
                    return false;
                }
                return {
                    level,
                    reason:
                        level === 1
                            ? "There are feature commits."
                            : "There are patch-level changes.",
                };
            },
        },
    },
} satisfies Config;
const config: Config = createReleaseConfig({
  githubTokenRef: "GITHUB_ANANKE_TOKEN_ADMIN_PRIVATE",
  scopes: {
    minorTypes: ["feat"],
    patchTypes: [
      "fix",
      "build",
      "chore",
      "ci",
      "docs",
      "perf",
      "refactor",
      "revert",
      "style",
      "test",
      "ai",
    ],
  },
  overrides: {
    git: {
      requireCleanWorkingDir: true,
      requireBranch: currentBranch,
      commit: true,
      // biome-ignore lint/suspicious/noTemplateCurlyInString: release-it expands this placeholder.
      commitMessage: "chore(release): v${version}",
      commitArgs: ["--no-verify"],
      tag: true,
      // biome-ignore lint/suspicious/noTemplateCurlyInString: release-it expands this placeholder.
      tagName: "v${version}",
      push: true,
      pushArgs: ["--follow-tags"],
    },
    github: {
      release: true,
      // biome-ignore lint/suspicious/noTemplateCurlyInString: release-it expands this placeholder.
      releaseName: "Release v${version}",
      skipChecks: true,
      tokenRef: "GITHUB_ANANKE_TOKEN_ADMIN_PRIVATE",
      comments: {
        submit: true,
      },
      discussionCategoryName: "1-release-notes",
    },
  },
});
export default config;
.vscode/instructions/shortcodes.instructions.md
New file
@@ -0,0 +1,17 @@
---
title: "Shortcodes"
applyTo: "layouts/_shortcodes/**/*.html"
description: This file describes the code style for shortcodes in the project.
---
* DO use [InnerDeindent](https://gohugo.io/functions/innerdeindent/) to format the content of shortcodes. This will remove any common leading whitespace from the content, making it easier to read and maintain. Markdown linting will warn/error on indented code blocks and force the user to use backticks instead.
* DO accept both named and positional parameters in the shortcode.
* DO use [IsNamedParams](https://gohugo.io/methods/shortcode/isnamedparams/) to check if the shortcode is being called with named parameters. This will allow you to handle both named and positional parameters in your shortcode.
* DO use [Page](https://gohugo.io/methods/shortcode/page/) and [Site](https://gohugo.io/methods/shortcode/site/) from the shortcode context to access page and site variables.
* DO NOT add any strings that are part of the output to the shortcode. These strings must be defined ino the i18n/en.toml file and accessed using the [`language.Translate`](https://gohugo.io/functions/lang/translate/) function.
* DO add documentation of all new or changed features in shortcodes in the documentation repository.
.vscode/prompts/test-hugo-versions.prompt.md
New file
@@ -0,0 +1,300 @@
---
description: Test an Ananke website against multiple local Hugo versions
---
# Test ananke websites with local hugo versions
Use these instructions when testing an Ananke-based website against multiple local Hugo binaries.
The goal is to verify that the website builds correctly across a requested Hugo version range, collect full logs per Hugo version, and compare output differences, warnings, and errors.
## Hugo executable location
Assume Hugo binaries are stored in:
`~/github.com/davidsneighbour/hugov/executables/`
The binary names follow this pattern:
```text
hugo-v0.162.1
hugo-v0.162.1-extended
```
Only use the **non-extended** Hugo binaries for these tests.
Do not use binaries ending in:
```text
-extended
```
## Version range interpretation
When the user says something like:
```text
test between 160 and 162
```
interpret it as:
```text
v0.160.0 <= version < v0.163.0
```
In other words:
* `160` means all available `v0.160.x` versions.
* `161` means all available `v0.161.x` versions.
* `162` means all available `v0.162.x` versions.
* Include every patch version found locally in that range.
* Do not invent missing versions.
* Do not download Hugo versions.
* Only test versions that exist as local non-extended binaries.
Examples:
```text
test between 160 and 162
```
should include binaries such as:
```text
hugo-v0.160.0
hugo-v0.160.1
hugo-v0.161.0
hugo-v0.162.0
hugo-v0.162.1
```
but only if they exist locally.
It must exclude:
```text
hugo-v0.160.0-extended
hugo-v0.162.1-extended
```
## Required test workflow
Run the test from the root of the Ananke website repository unless the user explicitly gives another path.
For each matching Hugo binary:
1. Resolve the absolute path of the Hugo binary.
2. Extract the semantic version from the filename.
3. Run the Hugo build command using that binary.
4. Capture stdout and stderr.
5. Store the full output in a log file.
6. Record the exit status.
7. Record warnings.
8. Record errors.
9. Compare the generated output and command output across all tested versions.
10. Summarise differences.
## Log directory
Store logs in:
```text
~/.logs/ananke-hugo-tests/
```
Create the directory if it does not exist.
Each test run should create a timestamped run directory:
```text
~/.logs/ananke-hugo-tests/YYYYMMDD-HHMMSS/
```
Inside that directory, write one log file per Hugo version.
Use filenames sorted naturally by version:
```text
hugo-v0.160.0.log
hugo-v0.160.1.log
hugo-v0.161.0.log
hugo-v0.162.1.log
```
Also create a summary file:
```text
summary.md
```
The summary file must include:
* tested repository path
* test timestamp
* requested version range
* resolved Hugo binaries
* command used per version
* exit status per version
* warning count per version
* error count per version
* output differences
* build output differences
* final judgement
## Hugo command
Use this default command unless the user requests another one:
```bash
hugo --gc --minify
```
Before each Hugo run, remove the generated output directory if it exists.
Default output directory:
```text
public/
```
This prevents stale output from one version affecting another version.
## Build output isolation
Each Hugo version must build into its own output directory so that generated files can be compared.
Use this pattern:
```text
.tmp/hugo-version-tests/v0.162.1/public/
```
Run Hugo with:
```bash
hugo --gc --minify --destination ".tmp/hugo-version-tests/v0.162.1/public"
```
Before each run, remove that version's output directory.
Do not remove unrelated folders.
## Comparison requirements
After all builds finish, compare:
1. Exit status
2. stdout and stderr
3. warnings
4. errors
5. generated file lists
6. generated file contents
The comparison must identify:
* versions that failed
* versions that passed
* warnings that appear only in specific versions
* errors that appear only in specific versions
* files generated by one version but missing in another
* files with changed contents
* relevant differences in generated HTML, CSS, JS, XML, JSON, and text files
Ignore irrelevant noise where appropriate, such as:
* timestamps generated by the build
* absolute temporary paths
* known cache paths
* ordering differences that do not change generated output
Do not hide real build differences.
## Warning and error detection
Scan logs case-insensitively for lines containing:
```text
warning
warn
error
failed
deprecated
panic
timeout
```
List matching lines grouped by Hugo version.
When warnings or errors repeat across all versions, list them once and mark them as shared.
When warnings or errors only happen in some versions, highlight the affected versions.
## Required final response format
When reporting results, use this structure:
```markdown
## Hugo version test summary
Repository: `...`
Requested range: `...`
Resolved Hugo versions:
* `v0.160.0` - passed
* `v0.160.1` - passed
* `v0.161.0` - failed
* `v0.162.1` - passed
## Issues
### Errors
* `v0.161.0`: ...
### Warnings
* Shared across all versions: ...
* `v0.162.1` only: ...
## Output differences
### File list differences
* `v0.160.0` generated `...`, but `v0.162.1` did not.
### File content differences
* `public/index.html` differs between `v0.160.0` and `v0.162.1`.
  * Difference: ...
## Logs
Logs were written to:
`~/.logs/ananke-hugo-tests/YYYYMMDD-HHMMSS/`
Summary file:
`~/.logs/ananke-hugo-tests/YYYYMMDD-HHMMSS/summary.md`
## Final judgement
...
```
## Behaviour rules
* Do not use Hugo extended binaries.
* Do not download missing Hugo versions.
* Do not assume a patch version exists.
* Do not stop after the first failing version.
* Continue testing all resolved versions.
* Always keep the full log output.
* Always compare results after all runs.
* Always list the tested Hugo binary paths.
* Always show when no matching Hugo versions were found.
* Always distinguish between command output differences and generated site output differences.
* Always use explicit version numbers in the final summary.
.vscode/settings.json
@@ -1,35 +1,33 @@
{
    "editor.formatOnSave": true,
    "files.autoSave": "afterDelay",
    "files.autoSaveWhenNoErrors": true,
    "files.autoSaveWorkspaceFilesOnly": true,
    "cSpell.language": "en-GB",
    "cSpell.dictionaryDefinitions": [
        {
            "name": "workspace",
            "path": "./.vscode/custom-dictionary.txt",
            "addWords": true
        }
    ],
    "cSpell.dictionaries": [
        "workspace"
    ],
    "cSpell.enabledFileTypes": {
        "markdown": true,
        "plaintext": true,
        "json": true,
        "yaml": true,
        "yml": true,
        "typescript": true,
        "javascript": true
    },
    "explorer.fileNesting.enabled": true,
    "explorer.fileNesting.patterns": {
        "package.json": "package-lock.json, netlify.toml, .lintstagedrc.js, .release-it.ts, .markdownlint*, .nvmrc, lychee.toml, tsconfig.json, .coderabbit.yaml",
        "theme.toml": "go.mod, go.sum",
        "README.md": "CHANGELOG.md, LICENSE.md, CONTRIBUTING.md, RELEASES.md, DESIGN.md",
        "*.ts": "$(capture).md, $(capture).test.ts, $(capture).spec.ts, $(capture).d.ts, $(capture).map, $(capture).js, $(capture).jsx, $(capture).tsx"
    },
    "window.autoDetectColorScheme": false,
    "markdown.extension.tableFormatter.enabled": false
}
  "editor.formatOnSave": true,
  "files.autoSave": "afterDelay",
  "files.autoSaveWhenNoErrors": true,
  "files.autoSaveWorkspaceFilesOnly": true,
  "cSpell.language": "en-GB",
  "cSpell.dictionaryDefinitions": [
    {
      "name": "workspace",
      "path": "./.vscode/custom-dictionary.txt",
      "addWords": true
    }
  ],
  "cSpell.dictionaries": ["workspace"],
  "cSpell.enabledFileTypes": {
    "markdown": true,
    "plaintext": true,
    "json": true,
    "yaml": true,
    "yml": true,
    "typescript": true,
    "javascript": true
  },
  "explorer.fileNesting.enabled": true,
  "explorer.fileNesting.patterns": {
    "package.json": "package-lock.json, netlify.toml, lint-staged.config.ts, .release-it.ts, biome.json, renovate.json5, .nvmrc, lychee.toml, tsconfig.json, .coderabbit.yaml",
    "theme.toml": "go.mod, go.sum",
    "README.md": "CHANGELOG.md, LICENSE.md, CONTRIBUTING.md, RELEASES.md, DESIGN.md",
    "*.ts": "$(capture).md, $(capture).test.ts, $(capture).spec.ts, $(capture).d.ts, $(capture).map, $(capture).js, $(capture).jsx, $(capture).tsx"
  },
  "window.autoDetectColorScheme": false,
  "markdown.extension.tableFormatter.enabled": false
}
ananke.code-workspace
@@ -1,52 +1,52 @@
{
    "folders": [
        {
            "path": "."
        },
        {
            "path": "../documentation"
        },
        {
            "path": "../template-git-submod"
        },
        {
            "path": "../template-hugo-mod"
        }
    ],
    "settings": {
        "window.autoDetectColorScheme": false,
        "markdown.extension.tableFormatter.enabled": false,
        "chat.tools.terminal.autoApprove": {
            "git fetch": true,
            "git worktree": true,
            "git add": true,
            "git push": true
        },
        "markdown.extension.toc.unorderedList.marker": "*",
        "markdown.extension.toc.levels": "2..6"
    },
    "extensions": {
        "recommendations": [
            "ms-vscode.vscode-github-issue-notebooks",
            "github.vscode-pull-request-github",
            "pkief.material-icon-theme",
            "yzhang.markdown-all-in-one",
            "davidanson.vscode-markdownlint",
            "zizmor.zizmor-vscode",
            "streetsidesoftware.code-spell-checker-cspell-bundled-dictionaries",
            "budparr.language-hugo-vscode",
            "jinliming2.vscode-go-template",
            "gohugoio.gotmplfmt",
            "casualjim.gotemplate",
            "streetsidesoftware.code-spell-checker",
            "streetsidesoftware.code-spell-checker-british-english",
            "gohugoio.gotmplfmt",
            "anthropic.claude-code",
            "aaron-bond.better-comments",
            "redhat.vscode-xml",
            "redhat.vscode-yaml",
            "google.wireit",
            "dunstontc.vscode-go-syntax"
        ]
    }
}
  "folders": [
    {
      "path": "."
    },
    {
      "path": "../documentation"
    },
    {
      "path": "../template-git-submod"
    },
    {
      "path": "../template-hugo-mod"
    }
  ],
  "settings": {
    "window.autoDetectColorScheme": false,
    "markdown.extension.tableFormatter.enabled": false,
    "chat.tools.terminal.autoApprove": {
      "git fetch": true,
      "git worktree": true,
      "git add": true,
      "git push": true
    },
    "markdown.extension.toc.unorderedList.marker": "*",
    "markdown.extension.toc.levels": "2..6"
  },
  "extensions": {
    "recommendations": [
      "ms-vscode.vscode-github-issue-notebooks",
      "github.vscode-pull-request-github",
      "pkief.material-icon-theme",
      "yzhang.markdown-all-in-one",
      "davidanson.vscode-markdownlint",
      "zizmor.zizmor-vscode",
      "streetsidesoftware.code-spell-checker-cspell-bundled-dictionaries",
      "budparr.language-hugo-vscode",
      "jinliming2.vscode-go-template",
      "gohugoio.gotmplfmt",
      "casualjim.gotemplate",
      "streetsidesoftware.code-spell-checker",
      "streetsidesoftware.code-spell-checker-british-english",
      "gohugoio.gotmplfmt",
      "anthropic.claude-code",
      "aaron-bond.better-comments",
      "redhat.vscode-xml",
      "redhat.vscode-yaml",
      "google.wireit",
      "dunstontc.vscode-go-syntax"
    ]
  }
}
assets/ananke/css/_code.css
@@ -1,4 +1,5 @@
pre, .pre  {
pre,
.pre {
  overflow-x: auto;
  overflow-y: hidden;
}
@@ -47,7 +48,9 @@
  border-radius: 4px;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.15s ease-in-out, background-color 0.15s ease-in-out;
  transition:
    opacity 0.15s ease-in-out,
    background-color 0.15s ease-in-out;
}
/* Reveal on hover and when focused for keyboard users. */
@@ -77,4 +80,4 @@
  .code-block .code-copy {
    transition: none;
  }
}
}
assets/ananke/css/_hugo-internal-templates.css
@@ -5,18 +5,18 @@
.pagination li {
  display: inline-block;
  margin-right: .375rem;
  font-size: .875rem;
  margin-right: 0.375rem;
  font-size: 0.875rem;
  margin-bottom: 2.5em;
}
[dir="rtl"] .pagination li {
  margin-left: .375rem;
  margin-left: 0.375rem;
  margin-right: 0;
}
.pagination li a {
  padding: .5rem .625rem;
  padding: 0.5rem 0.625rem;
  background-color: white;
  color: #333;
  border: 1px solid #ddd;
assets/ananke/css/_social-icons.css
@@ -1,10 +1,10 @@
.ananke-socials a{
.ananke-socials a {
  display: inline-block;
  vertical-align: middle;
  color: #BABABA;
  color: #bababa;
  fill: currentColor;
}
.ananke-socials a .icon svg{
.ananke-socials a .icon svg {
  width: 32px;
  height: 32px;
}
assets/ananke/css/_styles.css
@@ -6,15 +6,14 @@
.lh-copy blockquote {
  display: block;
  font-size: .875em;
  font-size: 0.875em;
  margin-left: 2rem;
  margin-top: 2rem;
  margin-bottom: 2rem;
  border-left: 4px solid #ccc;
  padding-left: 1rem;
}
.nested-links a{
.nested-links a {
  overflow-wrap: break-word;
}
biome.json
New file
@@ -0,0 +1,11 @@
{
  "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
  "extends": ["./node_modules/@dnbhq/biome-config/config.json"],
  "files": {
    "includes": [
      "!!**/*.html",
      "!!assets/ananke/css/_tachyons.css",
      "!!assets/ananke/dist"
    ]
  }
}
lint-staged.config.ts
New file
@@ -0,0 +1,22 @@
import { createLintStagedConfig } from "@dnbhq/lintstaged-config";
import type { Configuration } from "lint-staged";
const config: Configuration = createLintStagedConfig({
  secrets: { enabled: false },
  markdown: {
    configPath:
      "./node_modules/@dnbhq/markdownlint-config/.markdownlint-cli2.jsonc",
  },
  yaml: {
    enabled: true,
    configPath: ".yamllint.yml",
  },
  overrides: {
    ".github/workflows/**/*.y(a?)ml": ["zizmor --no-exit-codes"],
    "package-lock.json": [
      "lockfile-lint --path package-lock.json --validate-https --allowed-hosts npm",
    ],
  },
});
export default config;
package-lock.json
Diff too large
package.json
@@ -1,90 +1,91 @@
{
    "name": "@gohugo-ananke/ananke",
    "description": "Ananke: A theme for Hugo Sites",
    "version": "2.19.1",
    "license": "MIT",
    "repository": "gohugo-ananke/ananke",
    "author": "Bud Parr (https://github.com/budparr)",
    "maintainers": [
        {
            "name": "Patrick Kollitsch",
            "url": "https://github.com/davidsneighbour"
        }
    ],
    "contributors": [
        {
            "name": "Regis Philibert",
            "url": "https://github.com/regisphilibert"
        }
    ],
    "bugs": "https://github.com/gohugo-ananke/ananke/issues",
    "homepage": "https://github.com/gohugo-ananke/ananke#readme",
    "keywords": [
        "hugo",
        "hugo-theme",
        "hugo-module"
    ],
    "devDependencies": {
        "@axe-core/playwright": "4.13.0",
        "@biomejs/biome": "2.5.14",
        "@github/markdownlint-github": "0.8.4",
        "@playwright/test": "1.63.0",
        "@release-it/conventional-changelog": "12.0.2",
        "@types/node": "26.6.3",
        "dotenv": "18.0.4",
        "lint-staged": "17.6.0",
        "lockfile-lint": "5.0.1",
        "markdownlint": "0.41.1",
        "markdownlint-cli2": "0.23.3",
        "markdownlint-rule-extended-ascii": "0.2.1",
        "markdownlint-rule-list-duplicates": "0.0.1",
        "markdownlint-rule-relative-links": "5.1.3",
        "markdownlint-rule-search-replace": "1.2.0",
        "markdownlint-rule-title-case-style": "0.4.3",
        "release-it": "21.1.0",
        "simple-git-hooks": "2.14.0",
        "typescript": "7.0.2",
        "yaml": "2.9.1"
    },
    "scripts": {
        "hook:commit": "lint-staged --config .lintstagedrc.js",
        "hook:push": "node -e \"process.exit(require('child_process').execSync('git branch --show-current').toString().trim() === 'main' ? 0 : 1)\" && node scripts/test-hugo-quickstart.ts || true",
        "lint:links": "lychee --config lychee.toml public",
        "lint:markdown": "markdownlint-cli2",
        "lint:markdown:fix": "markdownlint-cli2 --fix",
        "lint:typescript": "tsc -p tsconfig.json --noEmit",
        "prepare": "simple-git-hooks install",
        "release": "release-it --config .release-it.ts",
        "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\")"
    },
    "cspell": {
        "version": "0.2",
        "language": "en-GB",
        "ignorePaths": [
            "CHANGELOG.md",
            "public",
            "node_modules"
        ]
    },
    "simple-git-hooks": {
        "pre-commit": "npm run hook:commit",
        "pre-push": "npm run hook:push"
    },
    "type": "module",
    "overrides": {
        "markdownlint-rule-title-case-style": {
            "markdownlint": "$markdownlint"
        }
    },
    "allowScripts": {
        "simple-git-hooks@2.14.0": true
    }
  "name": "@gohugo-ananke/ananke",
  "description": "Ananke: A theme for Hugo Sites",
  "version": "2.19.1",
  "license": "MIT",
  "author": "Bud Parr (https://github.com/budparr)",
  "maintainers": [
    {
      "name": "Patrick Kollitsch",
      "url": "https://github.com/davidsneighbour"
    }
  ],
  "contributors": [
    {
      "name": "Regis Philibert",
      "url": "https://github.com/regisphilibert"
    }
  ],
  "bugs": "https://github.com/gohugo-ananke/ananke/issues",
  "homepage": "https://github.com/gohugo-ananke/ananke#readme",
  "keywords": [
    "hugo",
    "hugo-theme",
    "hugo-module"
  ],
  "devDependencies": {
    "@axe-core/playwright": "4.13.0",
    "@biomejs/biome": "2.5.14",
    "@dnbhq/biome-config": "0.3.1",
    "@dnbhq/lintstaged-config": "0.2.1",
    "@dnbhq/markdownlint-config": "0.2.11",
    "@dnbhq/release-config": "1.1.3",
    "@dnbhq/tsconfig": "0.1.6",
    "@playwright/test": "1.63.0",
    "@release-it/conventional-changelog": "12.0.2",
    "@types/node": "26.6.3",
    "dotenv": "18.0.4",
    "lint-staged": "17.6.0",
    "lockfile-lint": "5.0.1",
    "release-it": "21.1.0",
    "simple-git-hooks": "2.14.0",
    "typescript": "7.0.2",
    "yaml": "2.9.1"
  },
  "scripts": {
    "hook:commit": "lint-staged",
    "hook:push": "node -e \"process.exit(require('child_process').execSync('git branch --show-current').toString().trim() === 'main' ? 0 : 1)\" && node scripts/test-hugo-quickstart.ts || true",
    "lint:links": "lychee --config lychee.toml public",
    "lint:markdown": "markdownlint-cli2 --config ./node_modules/@dnbhq/markdownlint-config/.markdownlint-cli2.jsonc \"**/*.{md,mdx}\" \"#CHANGELOG.md\" \"#tests/.playwright/**\" \"#tests/fixtures/site/public/**\"",
    "lint:markdown:fix": "markdownlint-cli2 --config ./node_modules/@dnbhq/markdownlint-config/.markdownlint-cli2.jsonc --fix \"**/*.{md,mdx}\" \"#CHANGELOG.md\" \"#tests/.playwright/**\" \"#tests/fixtures/site/public/**\"",
    "lint:typescript": "tsc -p tsconfig.json --noEmit",
    "prepare": "simple-git-hooks install",
    "release": "release-it --config .release-it.ts",
    "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\")",
    "release:dry": "release-it --dry-run --config .release-it.ts",
    "lint:code": "biome check",
    "lint:lockfile": "lockfile-lint --path package-lock.json --validate-https --allowed-hosts npm",
    "lint": "npm run lint:code && npm run lint:lockfile && npm run lint:markdown && npm run lint:typescript",
    "check": "npm run lint && npm test && npm run test:coverage-gate",
    "lint:code:fix": "biome check --write"
  },
  "cspell": {
    "version": "0.2",
    "language": "en-GB",
    "ignorePaths": [
      "CHANGELOG.md",
      "public",
      "node_modules"
    ]
  },
  "simple-git-hooks": {
    "pre-commit": "npm run hook:commit",
    "pre-push": "npm run hook:push"
  },
  "type": "module",
  "allowScripts": {
    "simple-git-hooks@2.14.0": true
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/gohugo-ananke/ananke.git"
  }
}
playwright.config.ts
@@ -12,26 +12,26 @@
 * 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,
    },
  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,
  },
});
renovate.json5
New file
@@ -0,0 +1,7 @@
{
    "$schema": "https://docs.renovatebot.com/renovate-schema.json",
    "extends": ["github>dnbhq/renovate-config"],
    "assignees": ["davidsneighbour"],
    "baseBranches": ["development"],
    "labels": ["type:dependencies"],
}
scripts/synch-social-icons.ts
@@ -32,81 +32,81 @@
import process from "node:process";
type CliOptions = {
    dir: string;
    branch: string;
    apply: boolean;
    deleteMissing: boolean;
    onlyChanged: boolean;
    json: boolean;
    verbose: boolean;
    all: boolean;
    concurrency: number;
    help: boolean;
  dir: string;
  branch: string;
  apply: boolean;
  deleteMissing: boolean;
  onlyChanged: boolean;
  json: boolean;
  verbose: boolean;
  all: boolean;
  concurrency: number;
  help: boolean;
};
type FileResultStatus =
    | "same"
    | "changed"
    | "missing"
    | "would-create"
    | "created"
    | "would-delete"
    | "deleted"
    | "updated"
    | "error";
  | "same"
  | "changed"
  | "missing"
  | "would-create"
  | "created"
  | "would-delete"
  | "deleted"
  | "updated"
  | "error";
type FileResult = {
    fileName: string;
    localPath: string;
    remoteUrl: string;
    status: FileResultStatus;
    message: string;
  fileName: string;
  localPath: string;
  remoteUrl: string;
  status: FileResultStatus;
  message: string;
};
type Summary = {
    same: number;
    changed: number;
    missing: number;
    wouldCreate: number;
    created: number;
    wouldDelete: number;
    deleted: number;
    updated: number;
    errors: number;
  same: number;
  changed: number;
  missing: number;
  wouldCreate: number;
  created: number;
  wouldDelete: number;
  deleted: number;
  updated: number;
  errors: number;
};
type JsonOutput = {
    options: {
        dir: string;
        branch: string;
        apply: boolean;
        deleteMissing: boolean;
        onlyChanged: boolean;
        json: boolean;
        verbose: boolean;
        all: boolean;
        concurrency: number;
    };
    results: FileResult[];
    summary: Summary;
  options: {
    dir: string;
    branch: string;
    apply: boolean;
    deleteMissing: boolean;
    onlyChanged: boolean;
    json: boolean;
    verbose: boolean;
    all: boolean;
    concurrency: number;
  };
  results: FileResult[];
  summary: Summary;
};
type ProcessingTask =
    | {
            kind: "upstream";
            fileName: string;
      }
    | {
            kind: "local-only";
            fileName: string;
      };
  | {
      kind: "upstream";
      fileName: string;
    }
  | {
      kind: "local-only";
      fileName: string;
    };
const DEFAULT_DIRECTORY = "assets/ananke/socials";
const DEFAULT_BRANCH = "develop";
const SIMPLE_ICONS_OWNER = "simple-icons";
const SIMPLE_ICONS_REPO = "simple-icons";
const SIMPLE_ICONS_RAW_BASE =
    "https://raw.githubusercontent.com/simple-icons/simple-icons";
  "https://raw.githubusercontent.com/simple-icons/simple-icons";
const EXIT_SUCCESS = 0;
const EXIT_FAILURE = 1;
@@ -115,10 +115,10 @@
 * Print CLI help.
 */
function printHelp(): void {
    const scriptName = path.basename(process.argv[1] ?? "update-social-icons.ts");
  const scriptName = path.basename(process.argv[1] ?? "update-social-icons.ts");
    console.log(
        `
  console.log(
    `
Usage:
  ${scriptName} [options]
@@ -169,7 +169,7 @@
  * updated       local file was replaced because --apply was used
  * error         processing failed
`.trim(),
    );
  );
}
/**
@@ -179,121 +179,121 @@
 * @returns Parsed CLI options.
 */
function parseArgs(argv: string[]): CliOptions {
    const options: CliOptions = {
        dir: DEFAULT_DIRECTORY,
        branch: DEFAULT_BRANCH,
        apply: false,
        deleteMissing: false,
        onlyChanged: false,
        json: false,
        verbose: false,
        all: false,
        concurrency: 8,
        help: false,
    };
  const options: CliOptions = {
    dir: DEFAULT_DIRECTORY,
    branch: DEFAULT_BRANCH,
    apply: false,
    deleteMissing: false,
    onlyChanged: false,
    json: false,
    verbose: false,
    all: false,
    concurrency: 8,
    help: false,
  };
    for (let index = 0; index < argv.length; index += 1) {
        const arg = argv[index];
        if (arg === undefined) {
            continue;
        }
  for (let index = 0; index < argv.length; index += 1) {
    const arg = argv[index];
    if (arg === undefined) {
      continue;
    }
        if (arg === "--help") {
            options.help = true;
            continue;
        }
    if (arg === "--help") {
      options.help = true;
      continue;
    }
        if (arg === "--apply") {
            options.apply = true;
            continue;
        }
    if (arg === "--apply") {
      options.apply = true;
      continue;
    }
        if (arg === "--delete") {
            options.deleteMissing = true;
            continue;
        }
    if (arg === "--delete") {
      options.deleteMissing = true;
      continue;
    }
        if (arg === "--only-changed") {
            options.onlyChanged = true;
            continue;
        }
    if (arg === "--only-changed") {
      options.onlyChanged = true;
      continue;
    }
        if (arg === "--json") {
            options.json = true;
            continue;
        }
    if (arg === "--json") {
      options.json = true;
      continue;
    }
        if (arg === "--verbose") {
            options.verbose = true;
            continue;
        }
    if (arg === "--verbose") {
      options.verbose = true;
      continue;
    }
        if (arg === "--all") {
            options.all = true;
            continue;
        }
    if (arg === "--all") {
      options.all = true;
      continue;
    }
        if (arg === "--dir") {
            const value = argv[index + 1];
            if (!value) {
                throw new Error("Missing value for --dir");
            }
            options.dir = value;
            index += 1;
            continue;
        }
    if (arg === "--dir") {
      const value = argv[index + 1];
      if (!value) {
        throw new Error("Missing value for --dir");
      }
      options.dir = value;
      index += 1;
      continue;
    }
        if (arg.startsWith("--dir=")) {
            options.dir = arg.slice("--dir=".length);
            continue;
        }
    if (arg.startsWith("--dir=")) {
      options.dir = arg.slice("--dir=".length);
      continue;
    }
        if (arg === "--branch") {
            const value = argv[index + 1];
            if (!value) {
                throw new Error("Missing value for --branch");
            }
            options.branch = value;
            index += 1;
            continue;
        }
    if (arg === "--branch") {
      const value = argv[index + 1];
      if (!value) {
        throw new Error("Missing value for --branch");
      }
      options.branch = value;
      index += 1;
      continue;
    }
        if (arg.startsWith("--branch=")) {
            options.branch = arg.slice("--branch=".length);
            continue;
        }
    if (arg.startsWith("--branch=")) {
      options.branch = arg.slice("--branch=".length);
      continue;
    }
        if (arg === "--concurrency") {
            const value = argv[index + 1];
            if (!value) {
                throw new Error("Missing value for --concurrency");
            }
    if (arg === "--concurrency") {
      const value = argv[index + 1];
      if (!value) {
        throw new Error("Missing value for --concurrency");
      }
            const parsed = Number.parseInt(value, 10);
            if (!Number.isInteger(parsed) || parsed < 1) {
                throw new Error("--concurrency must be a positive integer");
            }
      const parsed = Number.parseInt(value, 10);
      if (!Number.isInteger(parsed) || parsed < 1) {
        throw new Error("--concurrency must be a positive integer");
      }
            options.concurrency = parsed;
            index += 1;
            continue;
        }
      options.concurrency = parsed;
      index += 1;
      continue;
    }
        if (arg.startsWith("--concurrency=")) {
            const value = arg.slice("--concurrency=".length);
            const parsed = Number.parseInt(value, 10);
            if (!Number.isInteger(parsed) || parsed < 1) {
                throw new Error("--concurrency must be a positive integer");
            }
    if (arg.startsWith("--concurrency=")) {
      const value = arg.slice("--concurrency=".length);
      const parsed = Number.parseInt(value, 10);
      if (!Number.isInteger(parsed) || parsed < 1) {
        throw new Error("--concurrency must be a positive integer");
      }
            options.concurrency = parsed;
            continue;
        }
      options.concurrency = parsed;
      continue;
    }
        throw new Error(`Unknown argument: ${arg}`);
    }
    throw new Error(`Unknown argument: ${arg}`);
  }
    return options;
  return options;
}
/**
@@ -304,7 +304,7 @@
 * @returns Raw GitHub URL.
 */
function buildRemoteUrl(branch: string, fileName: string): string {
    return `${SIMPLE_ICONS_RAW_BASE}/${encodeURIComponent(branch)}/icons/${encodeURIComponent(fileName)}`;
  return `${SIMPLE_ICONS_RAW_BASE}/${encodeURIComponent(branch)}/icons/${encodeURIComponent(fileName)}`;
}
/**
@@ -314,13 +314,13 @@
 * @returns Sorted list of SVG filenames.
 */
async function getLocalSvgFiles(directory: string): Promise<string[]> {
    const entries = await fs.readdir(directory, { withFileTypes: true });
  const entries = await fs.readdir(directory, { withFileTypes: true });
    return entries
        .filter((entry) => entry.isFile())
        .map((entry) => entry.name)
        .filter((fileName) => fileName.toLowerCase().endsWith(".svg"))
        .sort((left, right) => left.localeCompare(right));
  return entries
    .filter((entry) => entry.isFile())
    .map((entry) => entry.name)
    .filter((fileName) => fileName.toLowerCase().endsWith(".svg"))
    .sort((left, right) => left.localeCompare(right));
}
/**
@@ -330,52 +330,52 @@
 * @returns Sorted list of upstream SVG filenames.
 */
async function getUpstreamSvgFiles(branch: string): Promise<string[]> {
    const apiUrl = new URL(
        `https://api.github.com/repos/${encodeURIComponent(SIMPLE_ICONS_OWNER)}/${encodeURIComponent(SIMPLE_ICONS_REPO)}/contents/icons`,
    );
    apiUrl.searchParams.set("ref", branch);
  const apiUrl = new URL(
    `https://api.github.com/repos/${encodeURIComponent(SIMPLE_ICONS_OWNER)}/${encodeURIComponent(SIMPLE_ICONS_REPO)}/contents/icons`,
  );
  apiUrl.searchParams.set("ref", branch);
    const response = await fetch(apiUrl.toString(), {
        method: "GET",
        headers: {
            Accept: "application/vnd.github+json",
            "User-Agent": "davidsneighbour-social-icon-updater",
        },
    });
  const response = await fetch(apiUrl.toString(), {
    method: "GET",
    headers: {
      Accept: "application/vnd.github+json",
      "User-Agent": "davidsneighbour-social-icon-updater",
    },
  });
    if (!response.ok) {
        throw new Error(
            `Failed to retrieve upstream icon listing with HTTP ${response.status}`,
        );
    }
  if (!response.ok) {
    throw new Error(
      `Failed to retrieve upstream icon listing with HTTP ${response.status}`,
    );
  }
    const data: unknown = await response.json();
  const data: unknown = await response.json();
    if (!Array.isArray(data)) {
        throw new Error(
            "Unexpected response while retrieving upstream icon listing",
        );
    }
  if (!Array.isArray(data)) {
    throw new Error(
      "Unexpected response while retrieving upstream icon listing",
    );
  }
    const fileNames = data
        .flatMap((entry: unknown) => {
            if (
                typeof entry === "object" &&
                entry !== null &&
                "type" in entry &&
                "name" in entry &&
                entry.type === "file" &&
                typeof entry.name === "string" &&
                entry.name.toLowerCase().endsWith(".svg")
            ) {
                return [entry.name];
            }
  const fileNames = data
    .flatMap((entry: unknown) => {
      if (
        typeof entry === "object" &&
        entry !== null &&
        "type" in entry &&
        "name" in entry &&
        entry.type === "file" &&
        typeof entry.name === "string" &&
        entry.name.toLowerCase().endsWith(".svg")
      ) {
        return [entry.name];
      }
            return [];
        })
        .sort((left, right) => left.localeCompare(right));
      return [];
    })
    .sort((left, right) => left.localeCompare(right));
    return fileNames;
  return fileNames;
}
/**
@@ -387,25 +387,25 @@
 * @returns SVG content or null if not found.
 */
async function fetchRemoteSvg(remoteUrl: string): Promise<string | null> {
    const response = await fetch(remoteUrl, {
        method: "GET",
        headers: {
            Accept: "image/svg+xml,text/plain;q=0.9,*/*;q=0.8",
            "User-Agent": "davidsneighbour-social-icon-updater",
        },
    });
  const response = await fetch(remoteUrl, {
    method: "GET",
    headers: {
      Accept: "image/svg+xml,text/plain;q=0.9,*/*;q=0.8",
      "User-Agent": "davidsneighbour-social-icon-updater",
    },
  });
    if (response.status === 404) {
        return null;
    }
  if (response.status === 404) {
    return null;
  }
    if (!response.ok) {
        throw new Error(
            `Request failed with HTTP ${response.status} for ${remoteUrl}`,
        );
    }
  if (!response.ok) {
    throw new Error(
      `Request failed with HTTP ${response.status} for ${remoteUrl}`,
    );
  }
    return await response.text();
  return await response.text();
}
/**
@@ -427,96 +427,96 @@
 * @returns Processing result.
 */
async function processUpstreamFile(
    directory: string,
    fileName: string,
    branch: string,
    apply: boolean,
  directory: string,
  fileName: string,
  branch: string,
  apply: boolean,
): Promise<FileResult> {
    const localPath = path.join(directory, fileName);
    const remoteUrl = buildRemoteUrl(branch, fileName);
  const localPath = path.join(directory, fileName);
  const remoteUrl = buildRemoteUrl(branch, fileName);
    try {
        const remoteContent = await fetchRemoteSvg(remoteUrl);
  try {
    const remoteContent = await fetchRemoteSvg(remoteUrl);
        if (remoteContent === null) {
            return {
                fileName,
                localPath,
                remoteUrl,
                status: "missing",
                message: "No matching upstream file found",
            };
        }
    if (remoteContent === null) {
      return {
        fileName,
        localPath,
        remoteUrl,
        status: "missing",
        message: "No matching upstream file found",
      };
    }
        const localStats = await fs.stat(localPath).catch(() => null);
    const localStats = await fs.stat(localPath).catch(() => null);
        if (!localStats) {
            if (!apply) {
                return {
                    fileName,
                    localPath,
                    remoteUrl,
                    status: "would-create",
                    message: "Upstream file exists and would be created locally",
                };
            }
    if (!localStats) {
      if (!apply) {
        return {
          fileName,
          localPath,
          remoteUrl,
          status: "would-create",
          message: "Upstream file exists and would be created locally",
        };
      }
            await fs.writeFile(localPath, remoteContent, "utf8");
      await fs.writeFile(localPath, remoteContent, "utf8");
            return {
                fileName,
                localPath,
                remoteUrl,
                status: "created",
                message: "Upstream file existed and was created locally",
            };
        }
      return {
        fileName,
        localPath,
        remoteUrl,
        status: "created",
        message: "Upstream file existed and was created locally",
      };
    }
        const localContent = await fs.readFile(localPath, "utf8");
    const localContent = await fs.readFile(localPath, "utf8");
        if (localContent === remoteContent) {
            return {
                fileName,
                localPath,
                remoteUrl,
                status: "same",
                message: "Local file matches upstream",
            };
        }
    if (localContent === remoteContent) {
      return {
        fileName,
        localPath,
        remoteUrl,
        status: "same",
        message: "Local file matches upstream",
      };
    }
        if (!apply) {
            return {
                fileName,
                localPath,
                remoteUrl,
                status: "changed",
                message: "Upstream file differs from local file",
            };
        }
    if (!apply) {
      return {
        fileName,
        localPath,
        remoteUrl,
        status: "changed",
        message: "Upstream file differs from local file",
      };
    }
        await fs.writeFile(localPath, remoteContent, "utf8");
    await fs.writeFile(localPath, remoteContent, "utf8");
        return {
            fileName,
            localPath,
            remoteUrl,
            status: "updated",
            message: "Local file replaced with upstream version",
        };
    } catch (error: unknown) {
        const message =
            error instanceof Error
                ? error.message
                : "Unknown error while processing file";
    return {
      fileName,
      localPath,
      remoteUrl,
      status: "updated",
      message: "Local file replaced with upstream version",
    };
  } catch (error: unknown) {
    const message =
      error instanceof Error
        ? error.message
        : "Unknown error while processing file";
        return {
            fileName,
            localPath,
            remoteUrl,
            status: "error",
            message,
        };
    }
    return {
      fileName,
      localPath,
      remoteUrl,
      status: "error",
      message,
    };
  }
}
/**
@@ -530,59 +530,59 @@
 * @returns Processing result.
 */
async function processLocalOnlyFile(
    directory: string,
    fileName: string,
    branch: string,
    apply: boolean,
    deleteMissing: boolean,
  directory: string,
  fileName: string,
  branch: string,
  apply: boolean,
  deleteMissing: boolean,
): Promise<FileResult> {
    const localPath = path.join(directory, fileName);
    const remoteUrl = buildRemoteUrl(branch, fileName);
  const localPath = path.join(directory, fileName);
  const remoteUrl = buildRemoteUrl(branch, fileName);
    try {
        if (!deleteMissing) {
            return {
                fileName,
                localPath,
                remoteUrl,
                status: "missing",
                message: "No matching upstream file found",
            };
        }
  try {
    if (!deleteMissing) {
      return {
        fileName,
        localPath,
        remoteUrl,
        status: "missing",
        message: "No matching upstream file found",
      };
    }
        if (!apply) {
            return {
                fileName,
                localPath,
                remoteUrl,
                status: "would-delete",
                message: "No matching upstream file found; local file would be deleted",
            };
        }
    if (!apply) {
      return {
        fileName,
        localPath,
        remoteUrl,
        status: "would-delete",
        message: "No matching upstream file found; local file would be deleted",
      };
    }
        await fs.unlink(localPath);
    await fs.unlink(localPath);
        return {
            fileName,
            localPath,
            remoteUrl,
            status: "deleted",
            message: "No matching upstream file found; local file deleted",
        };
    } catch (error: unknown) {
        const message =
            error instanceof Error
                ? error.message
                : "Unknown error while processing file";
    return {
      fileName,
      localPath,
      remoteUrl,
      status: "deleted",
      message: "No matching upstream file found; local file deleted",
    };
  } catch (error: unknown) {
    const message =
      error instanceof Error
        ? error.message
        : "Unknown error while processing file";
        return {
            fileName,
            localPath,
            remoteUrl,
            status: "error",
            message,
        };
    }
    return {
      fileName,
      localPath,
      remoteUrl,
      status: "error",
      message,
    };
  }
}
/**
@@ -593,14 +593,14 @@
 * @returns True if the result should be shown.
 */
function shouldPrintResult(
    status: FileResultStatus,
    onlyChanged: boolean,
  status: FileResultStatus,
  onlyChanged: boolean,
): boolean {
    if (!onlyChanged) {
        return true;
    }
  if (!onlyChanged) {
    return true;
  }
    return status !== "same";
  return status !== "same";
}
/**
@@ -610,26 +610,26 @@
 * @param verbose Whether to include detail lines.
 */
function printResult(result: FileResult, verbose: boolean): void {
    const prefixByStatus: Record<FileResultStatus, string> = {
        same: "SAME",
        changed: "CHANGED",
        missing: "MISS",
        "would-create": "WOULD-CREATE",
        created: "CREATED",
        "would-delete": "WOULD-DELETE",
        deleted: "DELETED",
        updated: "UPDATED",
        error: "ERR",
    };
  const prefixByStatus: Record<FileResultStatus, string> = {
    same: "SAME",
    changed: "CHANGED",
    missing: "MISS",
    "would-create": "WOULD-CREATE",
    created: "CREATED",
    "would-delete": "WOULD-DELETE",
    deleted: "DELETED",
    updated: "UPDATED",
    error: "ERR",
  };
    console.log(
        `${prefixByStatus[result.status]} ${result.fileName} - ${result.message}`,
    );
  console.log(
    `${prefixByStatus[result.status]} ${result.fileName} - ${result.message}`,
  );
    if (verbose) {
        console.log(`  local:  ${result.localPath}`);
        console.log(`  remote: ${result.remoteUrl}`);
    }
  if (verbose) {
    console.log(`  local:  ${result.localPath}`);
    console.log(`  remote: ${result.remoteUrl}`);
  }
}
/**
@@ -639,19 +639,19 @@
 * @returns Summary counts.
 */
function buildSummary(results: FileResult[]): Summary {
    return {
        same: results.filter((entry) => entry.status === "same").length,
        changed: results.filter((entry) => entry.status === "changed").length,
        missing: results.filter((entry) => entry.status === "missing").length,
        wouldCreate: results.filter((entry) => entry.status === "would-create")
            .length,
        created: results.filter((entry) => entry.status === "created").length,
        wouldDelete: results.filter((entry) => entry.status === "would-delete")
            .length,
        deleted: results.filter((entry) => entry.status === "deleted").length,
        updated: results.filter((entry) => entry.status === "updated").length,
        errors: results.filter((entry) => entry.status === "error").length,
    };
  return {
    same: results.filter((entry) => entry.status === "same").length,
    changed: results.filter((entry) => entry.status === "changed").length,
    missing: results.filter((entry) => entry.status === "missing").length,
    wouldCreate: results.filter((entry) => entry.status === "would-create")
      .length,
    created: results.filter((entry) => entry.status === "created").length,
    wouldDelete: results.filter((entry) => entry.status === "would-delete")
      .length,
    deleted: results.filter((entry) => entry.status === "deleted").length,
    updated: results.filter((entry) => entry.status === "updated").length,
    errors: results.filter((entry) => entry.status === "error").length,
  };
}
/**
@@ -660,17 +660,17 @@
 * @param summary Summary counts.
 */
function printSummary(summary: Summary): void {
    console.log("");
    console.log("Summary");
    console.log(`  same:          ${summary.same}`);
    console.log(`  changed:       ${summary.changed}`);
    console.log(`  updated:       ${summary.updated}`);
    console.log(`  missing:       ${summary.missing}`);
    console.log(`  would-create:  ${summary.wouldCreate}`);
    console.log(`  created:       ${summary.created}`);
    console.log(`  would-delete:  ${summary.wouldDelete}`);
    console.log(`  deleted:       ${summary.deleted}`);
    console.log(`  errors:        ${summary.errors}`);
  console.log("");
  console.log("Summary");
  console.log(`  same:          ${summary.same}`);
  console.log(`  changed:       ${summary.changed}`);
  console.log(`  updated:       ${summary.updated}`);
  console.log(`  missing:       ${summary.missing}`);
  console.log(`  would-create:  ${summary.wouldCreate}`);
  console.log(`  created:       ${summary.created}`);
  console.log(`  would-delete:  ${summary.wouldDelete}`);
  console.log(`  deleted:       ${summary.deleted}`);
  console.log(`  errors:        ${summary.errors}`);
}
/**
@@ -681,20 +681,20 @@
 * @param active Number of currently active tasks.
 */
function renderProgress(
    processed: number,
    total: number,
    active: number,
  processed: number,
  total: number,
  active: number,
): void {
    process.stdout.write(
        `\rProcessing ${processed}/${total} (active: ${active})`,
    );
  process.stdout.write(
    `\rProcessing ${processed}/${total} (active: ${active})`,
  );
}
/**
 * Clear the progress line from the terminal.
 */
function clearProgressLine(): void {
    process.stdout.write("\r\x1b[K");
  process.stdout.write("\r\x1b[K");
}
/**
@@ -704,271 +704,271 @@
 * @returns Results in task order.
 */
async function runTasksWithConcurrency<TInput, TResult>(params: {
    items: TInput[];
    concurrency: number;
    worker: (item: TInput) => Promise<TResult>;
    onResult?: (
        result: TResult,
        processed: number,
        total: number,
        active: number,
    ) => void;
  items: TInput[];
  concurrency: number;
  worker: (item: TInput) => Promise<TResult>;
  onResult?: (
    result: TResult,
    processed: number,
    total: number,
    active: number,
  ) => void;
}): Promise<TResult[]> {
    const { items, concurrency, worker, onResult } = params;
  const { items, concurrency, worker, onResult } = params;
    const results = new Array<TResult>(items.length);
    let nextIndex = 0;
    let processed = 0;
    let active = 0;
  const results = new Array<TResult>(items.length);
  let nextIndex = 0;
  let processed = 0;
  let active = 0;
    async function runWorker(): Promise<void> {
        while (true) {
            const currentIndex = nextIndex;
            if (currentIndex >= items.length) {
                return;
            }
  async function runWorker(): Promise<void> {
    while (true) {
      const currentIndex = nextIndex;
      if (currentIndex >= items.length) {
        return;
      }
            nextIndex += 1;
            active += 1;
      nextIndex += 1;
      active += 1;
            try {
                // Index is bounds-checked above; TInput itself may include undefined.
                const result = await worker(items[currentIndex] as TInput);
                results[currentIndex] = result;
            } finally {
                active -= 1;
                processed += 1;
      try {
        // Index is bounds-checked above; TInput itself may include undefined.
        const result = await worker(items[currentIndex] as TInput);
        results[currentIndex] = result;
      } finally {
        active -= 1;
        processed += 1;
                const result = results[currentIndex];
                if (result !== undefined && onResult) {
                    onResult(result, processed, items.length, active);
                }
            }
        }
    }
        const result = results[currentIndex];
        if (result !== undefined && onResult) {
          onResult(result, processed, items.length, active);
        }
      }
    }
  }
    const workerCount = Math.min(concurrency, items.length);
    await Promise.all(
        Array.from({ length: workerCount }, async () => {
            await runWorker();
        }),
    );
  const workerCount = Math.min(concurrency, items.length);
  await Promise.all(
    Array.from({ length: workerCount }, async () => {
      await runWorker();
    }),
  );
    return results;
  return results;
}
/**
 * Main program entry point.
 */
async function main(): Promise<void> {
    const options = parseArgs(process.argv.slice(2));
  const options = parseArgs(process.argv.slice(2));
    if (options.help) {
        printHelp();
        process.exit(EXIT_SUCCESS);
    }
  if (options.help) {
    printHelp();
    process.exit(EXIT_SUCCESS);
  }
    const targetDirectory = path.resolve(options.dir);
  const targetDirectory = path.resolve(options.dir);
    const stats = await fs.stat(targetDirectory).catch(() => null);
    if (!stats || !stats.isDirectory()) {
        throw new Error(
            `Directory does not exist or is not a directory: ${targetDirectory}`,
        );
    }
  const stats = await fs.stat(targetDirectory).catch(() => null);
  if (!stats || !stats.isDirectory()) {
    throw new Error(
      `Directory does not exist or is not a directory: ${targetDirectory}`,
    );
  }
    const localFiles = await getLocalSvgFiles(targetDirectory);
  const localFiles = await getLocalSvgFiles(targetDirectory);
    let upstreamFiles: string[] = [];
    if (options.all) {
        upstreamFiles = await getUpstreamSvgFiles(options.branch);
    }
  let upstreamFiles: string[] = [];
  if (options.all) {
    upstreamFiles = await getUpstreamSvgFiles(options.branch);
  }
    const emptySummary: Summary = {
        same: 0,
        changed: 0,
        missing: 0,
        wouldCreate: 0,
        created: 0,
        wouldDelete: 0,
        deleted: 0,
        updated: 0,
        errors: 0,
    };
  const emptySummary: Summary = {
    same: 0,
    changed: 0,
    missing: 0,
    wouldCreate: 0,
    created: 0,
    wouldDelete: 0,
    deleted: 0,
    updated: 0,
    errors: 0,
  };
    const tasks: ProcessingTask[] = [];
  const tasks: ProcessingTask[] = [];
    if (options.all) {
        const upstreamSet = new Set(upstreamFiles);
  if (options.all) {
    const upstreamSet = new Set(upstreamFiles);
        for (const fileName of upstreamFiles) {
            tasks.push({
                kind: "upstream",
                fileName,
            });
        }
    for (const fileName of upstreamFiles) {
      tasks.push({
        kind: "upstream",
        fileName,
      });
    }
        for (const fileName of localFiles) {
            if (!upstreamSet.has(fileName)) {
                tasks.push({
                    kind: "local-only",
                    fileName,
                });
            }
        }
    } else {
        if (localFiles.length === 0) {
            if (options.json) {
                const emptyOutput: JsonOutput = {
                    options: {
                        dir: targetDirectory,
                        branch: options.branch,
                        apply: options.apply,
                        deleteMissing: options.deleteMissing,
                        onlyChanged: options.onlyChanged,
                        json: options.json,
                        verbose: options.verbose,
                        all: options.all,
                        concurrency: options.concurrency,
                    },
                    results: [],
                    summary: emptySummary,
                };
                console.log(JSON.stringify(emptyOutput, null, 2));
            } else {
                console.log(`No .svg files found in ${targetDirectory}`);
            }
    for (const fileName of localFiles) {
      if (!upstreamSet.has(fileName)) {
        tasks.push({
          kind: "local-only",
          fileName,
        });
      }
    }
  } else {
    if (localFiles.length === 0) {
      if (options.json) {
        const emptyOutput: JsonOutput = {
          options: {
            dir: targetDirectory,
            branch: options.branch,
            apply: options.apply,
            deleteMissing: options.deleteMissing,
            onlyChanged: options.onlyChanged,
            json: options.json,
            verbose: options.verbose,
            all: options.all,
            concurrency: options.concurrency,
          },
          results: [],
          summary: emptySummary,
        };
        console.log(JSON.stringify(emptyOutput, null, 2));
      } else {
        console.log(`No .svg files found in ${targetDirectory}`);
      }
            process.exit(EXIT_SUCCESS);
        }
      process.exit(EXIT_SUCCESS);
    }
        for (const fileName of localFiles) {
            tasks.push({
                kind: "upstream",
                fileName,
            });
        }
    }
    for (const fileName of localFiles) {
      tasks.push({
        kind: "upstream",
        fileName,
      });
    }
  }
    if (!options.json) {
        if (options.all) {
            console.log(
                `Syncing against all upstream SVG file(s) from simple-icons/icons/ into ${targetDirectory}`,
            );
            console.log(`Found ${upstreamFiles.length} upstream SVG file(s).`);
            console.log(`Found ${localFiles.length} local SVG file(s).`);
        } else {
            console.log(
                `Checking ${localFiles.length} local SVG file(s) in ${targetDirectory}`,
            );
        }
  if (!options.json) {
    if (options.all) {
      console.log(
        `Syncing against all upstream SVG file(s) from simple-icons/icons/ into ${targetDirectory}`,
      );
      console.log(`Found ${upstreamFiles.length} upstream SVG file(s).`);
      console.log(`Found ${localFiles.length} local SVG file(s).`);
    } else {
      console.log(
        `Checking ${localFiles.length} local SVG file(s) in ${targetDirectory}`,
      );
    }
        console.log(`Using simple-icons branch: ${options.branch}`);
        console.log(`Concurrency: ${options.concurrency}`);
        console.log(
            options.apply
                ? "Apply mode enabled. Changed files will be written."
                : "Dry run mode enabled. No files will be written.",
        );
    console.log(`Using simple-icons branch: ${options.branch}`);
    console.log(`Concurrency: ${options.concurrency}`);
    console.log(
      options.apply
        ? "Apply mode enabled. Changed files will be written."
        : "Dry run mode enabled. No files will be written.",
    );
        if (options.deleteMissing) {
            console.log(
                options.apply
                    ? "Delete mode enabled. Local files missing upstream will be deleted."
                    : "Delete mode enabled. Local files missing upstream will be reported as would-delete.",
            );
        }
    if (options.deleteMissing) {
      console.log(
        options.apply
          ? "Delete mode enabled. Local files missing upstream will be deleted."
          : "Delete mode enabled. Local files missing upstream will be reported as would-delete.",
      );
    }
        if (options.onlyChanged) {
            console.log("Only changed results will be shown.");
        }
    if (options.onlyChanged) {
      console.log("Only changed results will be shown.");
    }
        console.log("");
    }
    console.log("");
  }
    const results = await runTasksWithConcurrency<ProcessingTask, FileResult>({
        items: tasks,
        concurrency: options.concurrency,
        worker: async (task): Promise<FileResult> => {
            if (task.kind === "upstream") {
                const result = await processUpstreamFile(
                    targetDirectory,
                    task.fileName,
                    options.branch,
                    options.apply,
                );
  const results = await runTasksWithConcurrency<ProcessingTask, FileResult>({
    items: tasks,
    concurrency: options.concurrency,
    worker: async (task): Promise<FileResult> => {
      if (task.kind === "upstream") {
        const result = await processUpstreamFile(
          targetDirectory,
          task.fileName,
          options.branch,
          options.apply,
        );
                if (result.status === "missing" && !options.all) {
                    return await processLocalOnlyFile(
                        targetDirectory,
                        task.fileName,
                        options.branch,
                        options.apply,
                        options.deleteMissing,
                    );
                }
        if (result.status === "missing" && !options.all) {
          return await processLocalOnlyFile(
            targetDirectory,
            task.fileName,
            options.branch,
            options.apply,
            options.deleteMissing,
          );
        }
                return result;
            }
        return result;
      }
            return await processLocalOnlyFile(
                targetDirectory,
                task.fileName,
                options.branch,
                options.apply,
                options.deleteMissing,
            );
        },
        onResult: (result, processed, total, active): void => {
            if (!options.json) {
                renderProgress(processed, total, active);
      return await processLocalOnlyFile(
        targetDirectory,
        task.fileName,
        options.branch,
        options.apply,
        options.deleteMissing,
      );
    },
    onResult: (result, processed, total, active): void => {
      if (!options.json) {
        renderProgress(processed, total, active);
                if (shouldPrintResult(result.status, options.onlyChanged)) {
                    clearProgressLine();
                    printResult(result, options.verbose);
                    renderProgress(processed, total, active);
                }
            }
        },
    });
        if (shouldPrintResult(result.status, options.onlyChanged)) {
          clearProgressLine();
          printResult(result, options.verbose);
          renderProgress(processed, total, active);
        }
      }
    },
  });
    if (!options.json) {
        clearProgressLine();
    }
  if (!options.json) {
    clearProgressLine();
  }
    const summary = buildSummary(results);
  const summary = buildSummary(results);
    if (options.json) {
        const output: JsonOutput = {
            options: {
                dir: targetDirectory,
                branch: options.branch,
                apply: options.apply,
                deleteMissing: options.deleteMissing,
                onlyChanged: options.onlyChanged,
                json: options.json,
                verbose: options.verbose,
                all: options.all,
                concurrency: options.concurrency,
            },
            results: options.onlyChanged
                ? results.filter((result) => shouldPrintResult(result.status, true))
                : results,
            summary,
        };
  if (options.json) {
    const output: JsonOutput = {
      options: {
        dir: targetDirectory,
        branch: options.branch,
        apply: options.apply,
        deleteMissing: options.deleteMissing,
        onlyChanged: options.onlyChanged,
        json: options.json,
        verbose: options.verbose,
        all: options.all,
        concurrency: options.concurrency,
      },
      results: options.onlyChanged
        ? results.filter((result) => shouldPrintResult(result.status, true))
        : results,
      summary,
    };
        console.log(JSON.stringify(output, null, 2));
    } else {
        printSummary(summary);
    }
    console.log(JSON.stringify(output, null, 2));
  } else {
    printSummary(summary);
  }
    const hasErrors = results.some((entry) => entry.status === "error");
    process.exit(hasErrors ? EXIT_FAILURE : EXIT_SUCCESS);
  const hasErrors = results.some((entry) => entry.status === "error");
  process.exit(hasErrors ? EXIT_FAILURE : EXIT_SUCCESS);
}
main().catch((error: unknown) => {
    const message =
        error instanceof Error ? error.message : "Unknown fatal error";
    console.error(`Fatal error: ${message}`);
    process.exit(EXIT_FAILURE);
  const message =
    error instanceof Error ? error.message : "Unknown fatal error";
  console.error(`Fatal error: ${message}`);
  process.exit(EXIT_FAILURE);
});
scripts/test-hugo-quickstart.ts
@@ -3,15 +3,15 @@
import { spawn } from "node:child_process";
import { constants } from "node:fs";
import {
    access,
    copyFile,
    cp,
    mkdir,
    mkdtemp,
    readFile,
    rm,
    stat,
    writeFile,
  access,
  copyFile,
  cp,
  mkdir,
  mkdtemp,
  readFile,
  rm,
  stat,
  writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
@@ -23,20 +23,20 @@
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
interface CommandResult {
    code: number | null;
    signal: NodeJS.Signals | null;
    stdout: string;
    stderr: string;
    combined: string;
    durationMs: number;
  code: number | null;
  signal: NodeJS.Signals | null;
  stdout: string;
  stderr: string;
  combined: string;
  durationMs: number;
}
interface StepDefinition {
    name: string;
    command: string;
    args: string[];
    cwd: string;
    expectedFiles?: string[];
  name: string;
  command: string;
  args: string[];
  cwd: string;
  expectedFiles?: string[];
}
/**
@@ -52,49 +52,49 @@
type ThemeSource = "local" | "submodule";
interface RoutineOptions {
    projectName: string;
    themeSource: ThemeSource;
    themePath: string;
    themeRepo: string;
    themeDir: string;
    themeName: string;
    configFile: string;
    keepOnSuccess: boolean;
    keepOnFailure: boolean;
    verbose: boolean;
  projectName: string;
  themeSource: ThemeSource;
  themePath: string;
  themeRepo: string;
  themeDir: string;
  themeName: string;
  configFile: string;
  keepOnSuccess: boolean;
  keepOnFailure: boolean;
  verbose: boolean;
}
interface StepReport {
    step: string;
    commandLine: string;
    cwd: string;
    result: CommandResult;
  step: string;
  commandLine: string;
  cwd: string;
  result: CommandResult;
}
interface HtmlAssertion {
    description: string;
    test: (html: string) => boolean;
  description: string;
  test: (html: string) => boolean;
}
const DEFAULT_OPTIONS: RoutineOptions = {
    projectName: "quickstart",
    themeSource: "local",
    themePath: REPO_ROOT,
    themeRepo: "https://github.com/gohugo-ananke/ananke.git",
    themeDir: "themes/ananke",
    themeName: "ananke",
    configFile: "hugo.toml",
    keepOnSuccess: false,
    keepOnFailure: true,
    verbose: true,
  projectName: "quickstart",
  themeSource: "local",
  themePath: REPO_ROOT,
  themeRepo: "https://github.com/gohugo-ananke/ananke.git",
  themeDir: "themes/ananke",
  themeName: "ananke",
  configFile: "hugo.toml",
  keepOnSuccess: false,
  keepOnFailure: true,
  verbose: true,
};
/**
 * Print CLI help.
 */
function printHelp(): void {
    console.log(
        `
  console.log(
    `
Usage:
  node scripts/test-hugo-quickstart.ts [options]
@@ -113,7 +113,7 @@
  --quiet                       Reduce step logging
  --help                        Show this help
`.trim(),
    );
  );
}
/**
@@ -124,69 +124,69 @@
 * @throws Error when an unknown argument is passed.
 */
function parseArgs(argv: string[]): RoutineOptions {
    const options: RoutineOptions = { ...DEFAULT_OPTIONS };
  const options: RoutineOptions = { ...DEFAULT_OPTIONS };
    for (const arg of argv) {
        if (arg === "--help") {
            printHelp();
            process.exit(0);
        }
  for (const arg of argv) {
    if (arg === "--help") {
      printHelp();
      process.exit(0);
    }
        if (arg === "--keep-on-success") {
            options.keepOnSuccess = true;
            continue;
        }
    if (arg === "--keep-on-success") {
      options.keepOnSuccess = true;
      continue;
    }
        if (arg === "--no-keep-on-failure") {
            options.keepOnFailure = false;
            continue;
        }
    if (arg === "--no-keep-on-failure") {
      options.keepOnFailure = false;
      continue;
    }
        if (arg === "--quiet") {
            options.verbose = false;
            continue;
        }
    if (arg === "--quiet") {
      options.verbose = false;
      continue;
    }
        if (arg === "--use-submodule") {
            options.themeSource = "submodule";
            continue;
        }
    if (arg === "--use-submodule") {
      options.themeSource = "submodule";
      continue;
    }
        if (arg.startsWith("--theme-path=")) {
            options.themePath = resolve(arg.slice("--theme-path=".length));
            options.themeSource = "local";
            continue;
        }
    if (arg.startsWith("--theme-path=")) {
      options.themePath = resolve(arg.slice("--theme-path=".length));
      options.themeSource = "local";
      continue;
    }
        if (arg.startsWith("--project-name=")) {
            options.projectName = arg.slice("--project-name=".length);
            continue;
        }
    if (arg.startsWith("--project-name=")) {
      options.projectName = arg.slice("--project-name=".length);
      continue;
    }
        if (arg.startsWith("--theme-repo=")) {
            options.themeRepo = arg.slice("--theme-repo=".length);
            continue;
        }
    if (arg.startsWith("--theme-repo=")) {
      options.themeRepo = arg.slice("--theme-repo=".length);
      continue;
    }
        if (arg.startsWith("--theme-dir=")) {
            options.themeDir = arg.slice("--theme-dir=".length);
            continue;
        }
    if (arg.startsWith("--theme-dir=")) {
      options.themeDir = arg.slice("--theme-dir=".length);
      continue;
    }
        if (arg.startsWith("--theme-name=")) {
            options.themeName = arg.slice("--theme-name=".length);
            continue;
        }
    if (arg.startsWith("--theme-name=")) {
      options.themeName = arg.slice("--theme-name=".length);
      continue;
    }
        if (arg.startsWith("--config-file=")) {
            options.configFile = arg.slice("--config-file=".length);
            continue;
        }
    if (arg.startsWith("--config-file=")) {
      options.configFile = arg.slice("--config-file=".length);
      continue;
    }
        throw new Error(`Unknown argument: ${arg}`);
    }
    throw new Error(`Unknown argument: ${arg}`);
  }
    return options;
  return options;
}
/**
@@ -197,9 +197,9 @@
 * @returns Full command line.
 */
function formatCommand(command: string, args: string[]): string {
    return [command, ...args]
        .map((part) => (/\s/.test(part) ? JSON.stringify(part) : part))
        .join(" ");
  return [command, ...args]
    .map((part) => (/\s/.test(part) ? JSON.stringify(part) : part))
    .join(" ");
}
/**
@@ -211,50 +211,50 @@
 * @returns Command execution result.
 */
async function runCommand(
    command: string,
    args: string[],
    cwd: string,
  command: string,
  args: string[],
  cwd: string,
): Promise<CommandResult> {
    const started = Date.now();
  const started = Date.now();
    return new Promise<CommandResult>((resolve, reject) => {
        const child = spawn(command, args, {
            cwd,
            env: process.env,
            stdio: ["ignore", "pipe", "pipe"],
        });
  return new Promise<CommandResult>((resolve, reject) => {
    const child = spawn(command, args, {
      cwd,
      env: process.env,
      stdio: ["ignore", "pipe", "pipe"],
    });
        let stdout = "";
        let stderr = "";
    let stdout = "";
    let stderr = "";
        child.stdout.on("data", (chunk: Buffer | string) => {
            stdout += chunk.toString();
        });
    child.stdout.on("data", (chunk: Buffer | string) => {
      stdout += chunk.toString();
    });
        child.stderr.on("data", (chunk: Buffer | string) => {
            stderr += chunk.toString();
        });
    child.stderr.on("data", (chunk: Buffer | string) => {
      stderr += chunk.toString();
    });
        child.on("error", (error: Error) => {
            reject(error);
        });
    child.on("error", (error: Error) => {
      reject(error);
    });
        child.on("close", (code, signal) => {
            const durationMs = Date.now() - started;
            const combined = [stdout, stderr]
                .filter(Boolean)
                .join(stdout && stderr ? "\n" : "");
    child.on("close", (code, signal) => {
      const durationMs = Date.now() - started;
      const combined = [stdout, stderr]
        .filter(Boolean)
        .join(stdout && stderr ? "\n" : "");
            resolve({
                code,
                signal,
                stdout,
                stderr,
                combined,
                durationMs,
            });
        });
    });
      resolve({
        code,
        signal,
        stdout,
        stderr,
        combined,
        durationMs,
      });
    });
  });
}
/**
@@ -263,7 +263,7 @@
 * @param filePath Absolute path to check.
 */
async function assertFileExists(filePath: string): Promise<void> {
    await access(filePath, constants.F_OK);
  await access(filePath, constants.F_OK);
}
/**
@@ -272,17 +272,17 @@
 * @param filePath Absolute path to check.
 */
async function assertFileDoesNotExist(filePath: string): Promise<void> {
    try {
        await access(filePath, constants.F_OK);
        throw new Error(`Unexpected path exists: ${filePath}`);
    } catch (error: unknown) {
        if (
            error instanceof Error &&
            error.message.startsWith("Unexpected path exists:")
        ) {
            throw error;
        }
    }
  try {
    await access(filePath, constants.F_OK);
    throw new Error(`Unexpected path exists: ${filePath}`);
  } catch (error: unknown) {
    if (
      error instanceof Error &&
      error.message.startsWith("Unexpected path exists:")
    ) {
      throw error;
    }
  }
}
/**
@@ -292,7 +292,7 @@
 * @returns File contents.
 */
async function readTextFile(filePath: string): Promise<string> {
    return readFile(filePath, "utf8");
  return readFile(filePath, "utf8");
}
/**
@@ -302,7 +302,7 @@
 * @param content File contents.
 */
async function writeTextFile(filePath: string, content: string): Promise<void> {
    await writeFile(filePath, content, "utf8");
  await writeFile(filePath, content, "utf8");
}
/**
@@ -311,8 +311,8 @@
 * @param projectRoot Absolute path to the temporary quickstart project.
 */
async function removePublicDir(projectRoot: string): Promise<void> {
    const publicPath = join(projectRoot, "public");
    await rm(publicPath, { recursive: true, force: true });
  const publicPath = join(projectRoot, "public");
  await rm(publicPath, { recursive: true, force: true });
}
/**
@@ -323,60 +323,60 @@
 * @throws Error when the command fails or an expected file is missing.
 */
async function executeStep(step: StepDefinition): Promise<StepReport> {
    const result = await runCommand(step.command, step.args, step.cwd);
    const commandLine = formatCommand(step.command, step.args);
  const result = await runCommand(step.command, step.args, step.cwd);
  const commandLine = formatCommand(step.command, step.args);
    if (result.code !== 0) {
        const details = [
            `Step failed: ${step.name}`,
            `Command: ${commandLine}`,
            `Working directory: ${step.cwd}`,
            `Exit code: ${String(result.code)}`,
            result.signal ? `Signal: ${result.signal}` : "",
            result.stdout ? `STDOUT:\n${result.stdout}` : "",
            result.stderr ? `STDERR:\n${result.stderr}` : "",
        ]
            .filter(Boolean)
            .join("\n\n");
  if (result.code !== 0) {
    const details = [
      `Step failed: ${step.name}`,
      `Command: ${commandLine}`,
      `Working directory: ${step.cwd}`,
      `Exit code: ${String(result.code)}`,
      result.signal ? `Signal: ${result.signal}` : "",
      result.stdout ? `STDOUT:\n${result.stdout}` : "",
      result.stderr ? `STDERR:\n${result.stderr}` : "",
    ]
      .filter(Boolean)
      .join("\n\n");
        throw new Error(details);
    }
    throw new Error(details);
  }
    if (step.expectedFiles) {
        for (const relativePath of step.expectedFiles) {
            const absolutePath = join(step.cwd, relativePath);
  if (step.expectedFiles) {
    for (const relativePath of step.expectedFiles) {
      const absolutePath = join(step.cwd, relativePath);
            try {
                await assertFileExists(absolutePath);
            } catch (error: unknown) {
                const message =
                    error instanceof Error
                        ? error.message
                        : "Unknown file assertion error";
      try {
        await assertFileExists(absolutePath);
      } catch (error: unknown) {
        const message =
          error instanceof Error
            ? error.message
            : "Unknown file assertion error";
                throw new Error(
                    [
                        `Step failed: ${step.name}`,
                        `Command: ${commandLine}`,
                        `Working directory: ${step.cwd}`,
                        `Expected file missing: ${absolutePath}`,
                        `Details: ${message}`,
                        result.stdout ? `STDOUT:\n${result.stdout}` : "",
                        result.stderr ? `STDERR:\n${result.stderr}` : "",
                    ]
                        .filter(Boolean)
                        .join("\n\n"),
                );
            }
        }
    }
        throw new Error(
          [
            `Step failed: ${step.name}`,
            `Command: ${commandLine}`,
            `Working directory: ${step.cwd}`,
            `Expected file missing: ${absolutePath}`,
            `Details: ${message}`,
            result.stdout ? `STDOUT:\n${result.stdout}` : "",
            result.stderr ? `STDERR:\n${result.stderr}` : "",
          ]
            .filter(Boolean)
            .join("\n\n"),
        );
      }
    }
  }
    return {
        step: step.name,
        commandLine,
        cwd: step.cwd,
        result,
    };
  return {
    step: step.name,
    commandLine,
    cwd: step.cwd,
    result,
  };
}
/**
@@ -386,19 +386,19 @@
 * @returns True when the command is a build command.
 */
function isHugoBuildCommand(step: StepDefinition): boolean {
    if (step.command !== "hugo") {
        return false;
    }
  if (step.command !== "hugo") {
    return false;
  }
    if (step.args.length === 0) {
        return true;
    }
  if (step.args.length === 0) {
    return true;
  }
    if (step.args.includes("--buildDrafts")) {
        return true;
    }
  if (step.args.includes("--buildDrafts")) {
    return true;
  }
    return false;
  return false;
}
/**
@@ -409,11 +409,11 @@
 * @returns Step report.
 */
async function executeHugoBuildStep(
    step: StepDefinition,
    projectRoot: string,
  step: StepDefinition,
  projectRoot: string,
): Promise<StepReport> {
    await removePublicDir(projectRoot);
    return executeStep(step);
  await removePublicDir(projectRoot);
  return executeStep(step);
}
/**
@@ -423,7 +423,7 @@
 * @returns Escaped string.
 */
function escapeRegExp(value: string): string {
    return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
@@ -439,26 +439,26 @@
 * @throws Error when the config does not contain the expected theme line.
 */
async function assertThemeConfigured(
    configPath: string,
    themeName: string,
  configPath: string,
  themeName: string,
): Promise<void> {
    const config = await readTextFile(configPath);
    const themePattern = new RegExp(
        String.raw`^\s*theme\s*=\s*['"]${escapeRegExp(themeName)}['"]\s*$`,
        "m",
    );
  const config = await readTextFile(configPath);
  const themePattern = new RegExp(
    String.raw`^\s*theme\s*=\s*['"]${escapeRegExp(themeName)}['"]\s*$`,
    "m",
  );
    if (!themePattern.test(config)) {
        throw new Error(
            [
                "Strict assertion failed: theme configuration missing or incorrect.",
                `Config file: ${configPath}`,
                `Expected to find a line like: theme = '${themeName}'`,
                "Actual file contents:",
                config,
            ].join("\n\n"),
        );
    }
  if (!themePattern.test(config)) {
    throw new Error(
      [
        "Strict assertion failed: theme configuration missing or incorrect.",
        `Config file: ${configPath}`,
        `Expected to find a line like: theme = '${themeName}'`,
        "Actual file contents:",
        config,
      ].join("\n\n"),
    );
  }
}
/**
@@ -467,38 +467,38 @@
 * @returns List of homepage assertions.
 */
function getHomepageAssertions(): HtmlAssertion[] {
    return [
        {
            description: "homepage contains an HTML document root",
            test: (html: string): boolean => /<html\b/i.test(html),
        },
        {
            description: "homepage contains a document title",
            test: (html: string): boolean => /<title>[\s\S]*?<\/title>/i.test(html),
        },
        {
            description: "homepage contains a body element",
            test: (html: string): boolean => /<body\b/i.test(html),
        },
        {
            description: "homepage contains at least one stylesheet reference",
            test: (html: string): boolean =>
                /<link\b[^>]*rel=["']stylesheet["'][^>]*>/i.test(html),
        },
        {
            description: "homepage contains at least one navigation-related landmark",
            test: (html: string): boolean => /<(nav|header)\b/i.test(html),
        },
        {
            description: "homepage contains theme-generated CSS class markers",
            test: (html: string): boolean =>
                /\b(ma[0-9]|pa[0-9]|bg-black|near-white|sans-serif)\b/i.test(html),
        },
        {
            description: "homepage contains a main content area",
            test: (html: string): boolean => /<(main|article|section)\b/i.test(html),
        },
    ];
  return [
    {
      description: "homepage contains an HTML document root",
      test: (html: string): boolean => /<html\b/i.test(html),
    },
    {
      description: "homepage contains a document title",
      test: (html: string): boolean => /<title>[\s\S]*?<\/title>/i.test(html),
    },
    {
      description: "homepage contains a body element",
      test: (html: string): boolean => /<body\b/i.test(html),
    },
    {
      description: "homepage contains at least one stylesheet reference",
      test: (html: string): boolean =>
        /<link\b[^>]*rel=["']stylesheet["'][^>]*>/i.test(html),
    },
    {
      description: "homepage contains at least one navigation-related landmark",
      test: (html: string): boolean => /<(nav|header)\b/i.test(html),
    },
    {
      description: "homepage contains theme-generated CSS class markers",
      test: (html: string): boolean =>
        /\b(ma[0-9]|pa[0-9]|bg-black|near-white|sans-serif)\b/i.test(html),
    },
    {
      description: "homepage contains a main content area",
      test: (html: string): boolean => /<(main|article|section)\b/i.test(html),
    },
  ];
}
/**
@@ -508,34 +508,34 @@
 * @throws Error when one or more assertions fail.
 */
async function assertHomepageLooksValid(homepagePath: string): Promise<void> {
    const html = await readTextFile(homepagePath);
    const failures: string[] = [];
  const html = await readTextFile(homepagePath);
  const failures: string[] = [];
    if (html.trim().length === 0) {
        throw new Error(
            [
                "Strict assertion failed: generated homepage is empty.",
                `Homepage file: ${homepagePath}`,
            ].join("\n\n"),
        );
    }
  if (html.trim().length === 0) {
    throw new Error(
      [
        "Strict assertion failed: generated homepage is empty.",
        `Homepage file: ${homepagePath}`,
      ].join("\n\n"),
    );
  }
    for (const assertion of getHomepageAssertions()) {
        if (!assertion.test(html)) {
            failures.push(`- ${assertion.description}`);
        }
    }
  for (const assertion of getHomepageAssertions()) {
    if (!assertion.test(html)) {
      failures.push(`- ${assertion.description}`);
    }
  }
    if (failures.length > 0) {
        throw new Error(
            [
                "Strict assertion failed: generated homepage did not match expected render checks.",
                `Homepage file: ${homepagePath}`,
                "Failed assertions:",
                ...failures,
            ].join("\n"),
        );
    }
  if (failures.length > 0) {
    throw new Error(
      [
        "Strict assertion failed: generated homepage did not match expected render checks.",
        `Homepage file: ${homepagePath}`,
        "Failed assertions:",
        ...failures,
      ].join("\n"),
    );
  }
}
/**
@@ -546,19 +546,19 @@
 * @throws Error when the date line is missing.
 */
function extractGeneratedDateLine(content: string): string {
    const match = content.match(/^\s*date\s*=\s*.+$/m);
  const match = content.match(/^\s*date\s*=\s*.+$/m);
    if (!match) {
        throw new Error(
            [
                "Strict assertion failed: could not find auto-generated date line in content file.",
                "Actual file contents:",
                content,
            ].join("\n\n"),
        );
    }
  if (!match) {
    throw new Error(
      [
        "Strict assertion failed: could not find auto-generated date line in content file.",
        "Actual file contents:",
        content,
      ].join("\n\n"),
    );
  }
    return match[0];
  return match[0];
}
/**
@@ -568,24 +568,24 @@
 * @param contentPath Absolute path to the content file.
 */
async function replaceGeneratedContent(contentPath: string): Promise<void> {
    const original = await readTextFile(contentPath);
    const dateLine = extractGeneratedDateLine(original);
  const original = await readTextFile(contentPath);
  const dateLine = extractGeneratedDateLine(original);
    const updated = [
        "+++",
        "title = 'My First Post'",
        dateLine,
        "draft = true",
        "+++",
        "## Introduction",
        "",
        "This is **bold** text, and this is *emphasized* text.",
        "",
        "Visit the [Hugo](https://gohugo.io) website!",
        "",
    ].join("\n");
  const updated = [
    "+++",
    "title = 'My First Post'",
    dateLine,
    "draft = true",
    "+++",
    "## Introduction",
    "",
    "This is **bold** text, and this is *emphasized* text.",
    "",
    "Visit the [Hugo](https://gohugo.io) website!",
    "",
  ].join("\n");
    await writeTextFile(contentPath, updated);
  await writeTextFile(contentPath, updated);
}
/**
@@ -595,18 +595,18 @@
 * @param themeName Theme name to set.
 */
async function replaceHugoConfig(
    configPath: string,
    themeName: string,
  configPath: string,
  themeName: string,
): Promise<void> {
    const content = [
        "baseURL = 'https://example.com/'",
        "locale = 'en-gb'",
        "title = 'Ananke Test Quickstart'",
        `theme = '${themeName}'`,
        "",
    ].join("\n");
  const content = [
    "baseURL = 'https://example.com/'",
    "locale = 'en-gb'",
    "title = 'Ananke Test Quickstart'",
    `theme = '${themeName}'`,
    "",
  ].join("\n");
    await writeTextFile(configPath, content);
  await writeTextFile(configPath, content);
}
/**
@@ -615,41 +615,41 @@
 * @param pageHtml HTML from `public/foo/index.html`.
 */
function assertDraftPageRendered(pageHtml: string): void {
    const failures: string[] = [];
  const failures: string[] = [];
    if (!/<h2[^>]*>\s*Introduction\s*<\/h2>/i.test(pageHtml)) {
        failures.push("- heading 'Introduction' was not rendered as an h2 element");
    }
  if (!/<h2[^>]*>\s*Introduction\s*<\/h2>/i.test(pageHtml)) {
    failures.push("- heading 'Introduction' was not rendered as an h2 element");
  }
    if (!/<strong>\s*bold\s*<\/strong>/i.test(pageHtml)) {
        failures.push("- bold Markdown was not rendered as a <strong> element");
    }
  if (!/<strong>\s*bold\s*<\/strong>/i.test(pageHtml)) {
    failures.push("- bold Markdown was not rendered as a <strong> element");
  }
    if (!/<em>\s*emphasized\s*<\/em>/i.test(pageHtml)) {
        failures.push("- emphasized Markdown was not rendered as an <em> element");
    }
  if (!/<em>\s*emphasized\s*<\/em>/i.test(pageHtml)) {
    failures.push("- emphasized Markdown was not rendered as an <em> element");
  }
    if (
        !/<a[^>]+href=["']https:\/\/gohugo\.io["'][^>]*>\s*Hugo\s*<\/a>/i.test(
            pageHtml,
        )
    ) {
        failures.push("- Markdown link was not rendered as an anchor element");
    }
  if (
    !/<a[^>]+href=["']https:\/\/gohugo\.io["'][^>]*>\s*Hugo\s*<\/a>/i.test(
      pageHtml,
    )
  ) {
    failures.push("- Markdown link was not rendered as an anchor element");
  }
    if (!/My First Post/i.test(pageHtml)) {
        failures.push("- post title was not visible on the rendered page");
    }
  if (!/My First Post/i.test(pageHtml)) {
    failures.push("- post title was not visible on the rendered page");
  }
    if (failures.length > 0) {
        throw new Error(
            [
                "Strict assertion failed: draft page content was not rendered as expected.",
                "Failed assertions:",
                ...failures,
            ].join("\n"),
        );
    }
  if (failures.length > 0) {
    throw new Error(
      [
        "Strict assertion failed: draft page content was not rendered as expected.",
        "Failed assertions:",
        ...failures,
      ].join("\n"),
    );
  }
}
/**
@@ -660,29 +660,29 @@
 * @param homepageHtml HTML from `public/index.html`.
 */
function assertUpdatedConfigInOutput(homepageHtml: string): void {
    const failures: string[] = [];
  const failures: string[] = [];
    if (!/Ananke Test Quickstart/i.test(homepageHtml)) {
        failures.push(
            "- updated site title was not visible in the generated output",
        );
    }
  if (!/Ananke Test Quickstart/i.test(homepageHtml)) {
    failures.push(
      "- updated site title was not visible in the generated output",
    );
  }
    if (!/<html[^>]+lang=["']en-gb["'][^>]*>/i.test(homepageHtml)) {
        failures.push(
            "- updated locale 'en-gb' was not present in the <html lang=\"en-gb\"> tag",
        );
    }
  if (!/<html[^>]+lang=["']en-gb["'][^>]*>/i.test(homepageHtml)) {
    failures.push(
      "- updated locale 'en-gb' was not present in the <html lang=\"en-gb\"> tag",
    );
  }
    if (failures.length > 0) {
        throw new Error(
            [
                "Strict assertion failed: updated config was not reflected in the generated output.",
                "Failed assertions:",
                ...failures,
            ].join("\n"),
        );
    }
  if (failures.length > 0) {
    throw new Error(
      [
        "Strict assertion failed: updated config was not reflected in the generated output.",
        "Failed assertions:",
        ...failures,
      ].join("\n"),
    );
  }
}
/**
@@ -692,22 +692,22 @@
 * @param homepagePath Absolute path to `public/index.html`.
 */
async function assertDraftHiddenInProduction(
    projectRoot: string,
    homepagePath: string,
  projectRoot: string,
  homepagePath: string,
): Promise<void> {
    const draftOutputPath = join(projectRoot, "public", "foo", "index.html");
    await assertFileDoesNotExist(draftOutputPath);
  const draftOutputPath = join(projectRoot, "public", "foo", "index.html");
  await assertFileDoesNotExist(draftOutputPath);
    const homepageHtml = await readTextFile(homepagePath);
  const homepageHtml = await readTextFile(homepagePath);
    if (/My First Post/i.test(homepageHtml)) {
        throw new Error(
            [
                "Strict assertion failed: draft post title was visible in the production homepage output.",
                `Homepage file: ${homepagePath}`,
            ].join("\n\n"),
        );
    }
  if (/My First Post/i.test(homepageHtml)) {
    throw new Error(
      [
        "Strict assertion failed: draft post title was visible in the production homepage output.",
        `Homepage file: ${homepagePath}`,
      ].join("\n\n"),
    );
  }
}
/**
@@ -733,31 +733,31 @@
 * @param contentDir Absolute path to the project `content` directory.
 */
async function writeHeaderSectionClassFixtures(
    contentDir: string,
  contentDir: string,
): Promise<void> {
    const overridePage = [
        "+++",
        "title = 'Custom Header Height'",
        "featured_image = '/images/custom-hero.jpg'",
        `header_section_class = '${HEADER_SECTION_CLASS_MARKER} ph3 ph4-ns'`,
        "+++",
        "",
        "Body.",
        "",
    ].join("\n");
  const overridePage = [
    "+++",
    "title = 'Custom Header Height'",
    "featured_image = '/images/custom-hero.jpg'",
    `header_section_class = '${HEADER_SECTION_CLASS_MARKER} ph3 ph4-ns'`,
    "+++",
    "",
    "Body.",
    "",
  ].join("\n");
    const defaultPage = [
        "+++",
        "title = 'Default Header Height'",
        "featured_image = '/images/default-hero.jpg'",
        "+++",
        "",
        "Body.",
        "",
    ].join("\n");
  const defaultPage = [
    "+++",
    "title = 'Default Header Height'",
    "featured_image = '/images/default-hero.jpg'",
    "+++",
    "",
    "Body.",
    "",
  ].join("\n");
    await writeTextFile(join(contentDir, "custom-header.md"), overridePage);
    await writeTextFile(join(contentDir, "default-header.md"), defaultPage);
  await writeTextFile(join(contentDir, "custom-header.md"), overridePage);
  await writeTextFile(join(contentDir, "default-header.md"), defaultPage);
}
/**
@@ -768,44 +768,44 @@
 * @throws Error when the override is dropped, leaks, or the default changes.
 */
async function assertHeaderSectionClassConfigurable(
    projectRoot: string,
  projectRoot: string,
): Promise<void> {
    const failures: string[] = [];
  const failures: string[] = [];
    const overrideHtml = await readTextFile(
        join(projectRoot, "public", "custom-header", "index.html"),
    );
    const defaultHtml = await readTextFile(
        join(projectRoot, "public", "default-header", "index.html"),
    );
  const overrideHtml = await readTextFile(
    join(projectRoot, "public", "custom-header", "index.html"),
  );
  const defaultHtml = await readTextFile(
    join(projectRoot, "public", "default-header", "index.html"),
  );
    if (!overrideHtml.includes(HEADER_SECTION_CLASS_MARKER)) {
        failures.push(
            `- custom 'header_section_class' value '${HEADER_SECTION_CLASS_MARKER}' was not applied to the hero header`,
        );
    }
  if (!overrideHtml.includes(HEADER_SECTION_CLASS_MARKER)) {
    failures.push(
      `- custom 'header_section_class' value '${HEADER_SECTION_CLASS_MARKER}' was not applied to the hero header`,
    );
  }
    if (defaultHtml.includes(HEADER_SECTION_CLASS_MARKER)) {
        failures.push(
            "- custom 'header_section_class' value leaked onto a page that did not set it",
        );
    }
  if (defaultHtml.includes(HEADER_SECTION_CLASS_MARKER)) {
    failures.push(
      "- custom 'header_section_class' value leaked onto a page that did not set it",
    );
  }
    if (!defaultHtml.includes(DEFAULT_PAGE_HEADER_SECTION_CLASS)) {
        failures.push(
            `- default header section spacing '${DEFAULT_PAGE_HEADER_SECTION_CLASS}' was missing when 'header_section_class' was not set`,
        );
    }
  if (!defaultHtml.includes(DEFAULT_PAGE_HEADER_SECTION_CLASS)) {
    failures.push(
      `- default header section spacing '${DEFAULT_PAGE_HEADER_SECTION_CLASS}' was missing when 'header_section_class' was not set`,
    );
  }
    if (failures.length > 0) {
        throw new Error(
            [
                "Strict assertion failed: configurable header section class did not behave as expected.",
                "Failed assertions:",
                ...failures,
            ].join("\n"),
        );
    }
  if (failures.length > 0) {
    throw new Error(
      [
        "Strict assertion failed: configurable header section class did not behave as expected.",
        "Failed assertions:",
        ...failures,
      ].join("\n"),
    );
  }
}
/**
@@ -827,35 +827,35 @@
 * @param contentDir Absolute path to the project `content` directory.
 */
async function writeListCardFixtures(contentDir: string): Promise<void> {
    const sectionDir = join(contentDir, "cards");
    await mkdir(sectionDir, { recursive: true });
  const sectionDir = join(contentDir, "cards");
  await mkdir(sectionDir, { recursive: true });
    const sectionIndex = ["+++", "title = 'Cards'", "+++", "", ""].join("\n");
  const sectionIndex = ["+++", "title = 'Cards'", "+++", "", ""].join("\n");
    const withImage = [
        "+++",
        "title = 'Card With Image'",
        "date = 2024-01-15T00:00:00Z",
        "featured_image = '/images/card-hero.jpg'",
        "+++",
        "",
        "Card body.",
        "",
    ].join("\n");
  const withImage = [
    "+++",
    "title = 'Card With Image'",
    "date = 2024-01-15T00:00:00Z",
    "featured_image = '/images/card-hero.jpg'",
    "+++",
    "",
    "Card body.",
    "",
  ].join("\n");
    const withoutImage = [
        "+++",
        "title = 'Card Without Image'",
        "date = 2024-02-20T00:00:00Z",
        "+++",
        "",
        "Card body.",
        "",
    ].join("\n");
  const withoutImage = [
    "+++",
    "title = 'Card Without Image'",
    "date = 2024-02-20T00:00:00Z",
    "+++",
    "",
    "Card body.",
    "",
  ].join("\n");
    await writeTextFile(join(sectionDir, "_index.md"), sectionIndex);
    await writeTextFile(join(sectionDir, "with-image.md"), withImage);
    await writeTextFile(join(sectionDir, "without-image.md"), withoutImage);
  await writeTextFile(join(sectionDir, "_index.md"), sectionIndex);
  await writeTextFile(join(sectionDir, "with-image.md"), withImage);
  await writeTextFile(join(sectionDir, "without-image.md"), withoutImage);
}
/**
@@ -868,47 +868,47 @@
 * @throws Error when the rendered cards do not match the expectations.
 */
function assertListCardSummaries(
    listHtml: string,
    label: string,
    expectations: { images: boolean; dates: boolean },
  listHtml: string,
  label: string,
  expectations: { images: boolean; dates: boolean },
): void {
    const failures: string[] = [];
    const hasImage = listHtml.includes(LIST_CARD_IMAGE_MARKER);
    const hasDate = listHtml.includes(SUMMARY_CARD_DATE_MARKER);
  const failures: string[] = [];
  const hasImage = listHtml.includes(LIST_CARD_IMAGE_MARKER);
  const hasDate = listHtml.includes(SUMMARY_CARD_DATE_MARKER);
    if (expectations.images && !hasImage) {
        failures.push(
            "- expected image summary cards but the list rendered no card image",
        );
    }
  if (expectations.images && !hasImage) {
    failures.push(
      "- expected image summary cards but the list rendered no card image",
    );
  }
    if (!expectations.images && hasImage) {
        failures.push(
            "- image summary cards were rendered when 'ananke.pages.show_list_images' was not enabled",
        );
    }
  if (!expectations.images && hasImage) {
    failures.push(
      "- image summary cards were rendered when 'ananke.pages.show_list_images' was not enabled",
    );
  }
    if (expectations.dates && !hasDate) {
        failures.push(
            "- expected summary card dates but none were rendered by default",
        );
    }
  if (expectations.dates && !hasDate) {
    failures.push(
      "- expected summary card dates but none were rendered by default",
    );
  }
    if (!expectations.dates && hasDate) {
        failures.push(
            "- summary card dates were rendered when 'ananke.pages.show_date' was false",
        );
    }
  if (!expectations.dates && hasDate) {
    failures.push(
      "- summary card dates were rendered when 'ananke.pages.show_date' was false",
    );
  }
    if (failures.length > 0) {
        throw new Error(
            [
                `Strict assertion failed: list card summaries (${label}) did not behave as expected.`,
                "Failed assertions:",
                ...failures,
            ].join("\n"),
        );
    }
  if (failures.length > 0) {
    throw new Error(
      [
        `Strict assertion failed: list card summaries (${label}) did not behave as expected.`,
        "Failed assertions:",
        ...failures,
      ].join("\n"),
    );
  }
}
/**
@@ -918,13 +918,13 @@
 * @returns True when `path` is inside a Git work tree.
 */
async function isGitWorkTree(path: string): Promise<boolean> {
    const result = await runCommand(
        "git",
        ["-C", path, "rev-parse", "--is-inside-work-tree"],
        path,
    );
  const result = await runCommand(
    "git",
    ["-C", path, "rev-parse", "--is-inside-work-tree"],
    path,
  );
    return result.code === 0 && result.stdout.trim() === "true";
  return result.code === 0 && result.stdout.trim() === "true";
}
/**
@@ -940,70 +940,70 @@
 * @throws Error when the source contains no theme files.
 */
async function copyLocalTheme(
    themePath: string,
    destination: string,
  themePath: string,
  destination: string,
): Promise<void> {
    if (await isGitWorkTree(themePath)) {
        const listing = await runCommand(
            "git",
            [
                "-C",
                themePath,
                "ls-files",
                "-z",
                "--cached",
                "--others",
                "--exclude-standard",
            ],
            themePath,
        );
  if (await isGitWorkTree(themePath)) {
    const listing = await runCommand(
      "git",
      [
        "-C",
        themePath,
        "ls-files",
        "-z",
        "--cached",
        "--others",
        "--exclude-standard",
      ],
      themePath,
    );
        if (listing.code !== 0) {
            throw new Error(
                `Failed to list theme files via git in ${themePath}:\n${listing.stderr}`,
            );
        }
    if (listing.code !== 0) {
      throw new Error(
        `Failed to list theme files via git in ${themePath}:\n${listing.stderr}`,
      );
    }
        const relativePaths = listing.stdout.split("\0").filter(Boolean);
    const relativePaths = listing.stdout.split("\0").filter(Boolean);
        if (relativePaths.length === 0) {
            throw new Error(`No theme files found in ${themePath}`);
        }
    if (relativePaths.length === 0) {
      throw new Error(`No theme files found in ${themePath}`);
    }
        for (const relativePath of relativePaths) {
            const source = join(themePath, relativePath);
    for (const relativePath of relativePaths) {
      const source = join(themePath, relativePath);
            try {
                const stats = await stat(source);
                if (!stats.isFile()) {
                    continue;
                }
            } catch {
                // Tracked but deleted in the work tree: nothing to copy.
                continue;
            }
      try {
        const stats = await stat(source);
        if (!stats.isFile()) {
          continue;
        }
      } catch {
        // Tracked but deleted in the work tree: nothing to copy.
        continue;
      }
            const target = join(destination, relativePath);
            await mkdir(dirname(target), { recursive: true });
            await copyFile(source, target);
        }
      const target = join(destination, relativePath);
      await mkdir(dirname(target), { recursive: true });
      await copyFile(source, target);
    }
        return;
    }
    return;
  }
    // Fallback for a non-Git source directory: copy recursively while excluding
    // heavy or generated paths that would never ship with the theme.
    const excludedNames = new Set(["node_modules", "public", ".git"]);
    await cp(themePath, destination, {
        recursive: true,
        filter: (source: string): boolean => {
            if (excludedNames.has(basename(source))) {
                return false;
            }
  // Fallback for a non-Git source directory: copy recursively while excluding
  // heavy or generated paths that would never ship with the theme.
  const excludedNames = new Set(["node_modules", "public", ".git"]);
  await cp(themePath, destination, {
    recursive: true,
    filter: (source: string): boolean => {
      if (excludedNames.has(basename(source))) {
        return false;
      }
            return !source.includes(join("resources", "_gen"));
        },
    });
      return !source.includes(join("resources", "_gen"));
    },
  });
}
/**
@@ -1016,48 +1016,48 @@
 * @throws Error when installation fails or the theme is incomplete.
 */
async function installTheme(
    options: RoutineOptions,
    projectRoot: string,
    reports: StepReport[],
  options: RoutineOptions,
  projectRoot: string,
  reports: StepReport[],
): Promise<void> {
    const destination = join(projectRoot, options.themeDir);
  const destination = join(projectRoot, options.themeDir);
    if (options.verbose) {
        console.log(`\n[RUN] Install theme (source: ${options.themeSource})`);
    }
  if (options.verbose) {
    console.log(`\n[RUN] Install theme (source: ${options.themeSource})`);
  }
    if (options.themeSource === "submodule") {
        const step: StepDefinition = {
            name: "Add theme as Git submodule",
            command: "git",
            args: ["submodule", "add", options.themeRepo, options.themeDir],
            cwd: projectRoot,
            expectedFiles: [options.themeDir, ".gitmodules"],
        };
        const report = await executeStep(step);
        reports.push(report);
  if (options.themeSource === "submodule") {
    const step: StepDefinition = {
      name: "Add theme as Git submodule",
      command: "git",
      args: ["submodule", "add", options.themeRepo, options.themeDir],
      cwd: projectRoot,
      expectedFiles: [options.themeDir, ".gitmodules"],
    };
    const report = await executeStep(step);
    reports.push(report);
        if (options.verbose) {
            console.log(
                `[OK ] ${step.name} (${report.result.durationMs} ms, exit ${String(report.result.code)})`,
            );
        }
    if (options.verbose) {
      console.log(
        `[OK ] ${step.name} (${report.result.durationMs} ms, exit ${String(report.result.code)})`,
      );
    }
        return;
    }
    return;
  }
    const started = Date.now();
    await copyLocalTheme(options.themePath, destination);
  const started = Date.now();
  await copyLocalTheme(options.themePath, destination);
    // Sanity check: a usable theme must at least expose theme.toml and layouts.
    await assertFileExists(join(destination, "theme.toml"));
    await assertFileExists(join(destination, "layouts"));
  // Sanity check: a usable theme must at least expose theme.toml and layouts.
  await assertFileExists(join(destination, "theme.toml"));
  await assertFileExists(join(destination, "layouts"));
    if (options.verbose) {
        console.log(
            `[OK ] Copied local theme from ${options.themePath} (${Date.now() - started} ms)`,
        );
    }
  if (options.verbose) {
    console.log(
      `[OK ] Copied local theme from ${options.themePath} (${Date.now() - started} ms)`,
    );
  }
}
/**
@@ -1069,34 +1069,34 @@
 * @param reports Accumulated step reports (appended to).
 */
async function runSteps(
    steps: StepDefinition[],
    options: RoutineOptions,
    projectRoot: string,
    reports: StepReport[],
  steps: StepDefinition[],
  options: RoutineOptions,
  projectRoot: string,
  reports: StepReport[],
): Promise<void> {
    for (const step of steps) {
        if (options.verbose) {
            console.log(`\n[RUN] ${step.name}`);
            console.log(`      ${formatCommand(step.command, step.args)}`);
        }
  for (const step of steps) {
    if (options.verbose) {
      console.log(`\n[RUN] ${step.name}`);
      console.log(`      ${formatCommand(step.command, step.args)}`);
    }
        const report = isHugoBuildCommand(step)
            ? await executeHugoBuildStep(step, projectRoot)
            : await executeStep(step);
    const report = isHugoBuildCommand(step)
      ? await executeHugoBuildStep(step, projectRoot)
      : await executeStep(step);
        reports.push(report);
    reports.push(report);
        if (options.verbose) {
            console.log(
                `[OK ] ${step.name} (${report.result.durationMs} ms, exit ${String(report.result.code)})`,
            );
    if (options.verbose) {
      console.log(
        `[OK ] ${step.name} (${report.result.durationMs} ms, exit ${String(report.result.code)})`,
      );
            const trimmedOutput = report.result.combined.trim();
            if (trimmedOutput) {
                console.log(trimmedOutput);
            }
        }
    }
      const trimmedOutput = report.result.combined.trim();
      if (trimmedOutput) {
        console.log(trimmedOutput);
      }
    }
  }
}
/**
@@ -1106,360 +1106,354 @@
 * @returns Process exit code.
 */
async function runRoutine(options: RoutineOptions): Promise<number> {
    const sandboxRoot = await mkdtemp(join(tmpdir(), "hugo-quickstart-"));
    const projectRoot = join(sandboxRoot, options.projectName);
  const sandboxRoot = await mkdtemp(join(tmpdir(), "hugo-quickstart-"));
  const projectRoot = join(sandboxRoot, options.projectName);
    const reports: StepReport[] = [];
  const reports: StepReport[] = [];
    // Steps that prepare the project before the theme is installed.
    const setupSteps: StepDefinition[] = [
        {
            name: "Create Hugo project",
            command: "hugo",
            args: ["new", "project", options.projectName],
            cwd: sandboxRoot,
            expectedFiles: [join(options.projectName, options.configFile)],
        },
        {
            name: "Initialise Git repository",
            command: "git",
            args: ["init"],
            cwd: projectRoot,
            expectedFiles: [".git"],
        },
    ];
  // Steps that prepare the project before the theme is installed.
  const setupSteps: StepDefinition[] = [
    {
      name: "Create Hugo project",
      command: "hugo",
      args: ["new", "project", options.projectName],
      cwd: sandboxRoot,
      expectedFiles: [join(options.projectName, options.configFile)],
    },
    {
      name: "Initialise Git repository",
      command: "git",
      args: ["init"],
      cwd: projectRoot,
      expectedFiles: [".git"],
    },
  ];
    // Steps that run once the theme is in place.
    const buildSteps: StepDefinition[] = [
        {
            name: "Configure theme in Hugo config",
            command: "bash",
            args: [
                "-lc",
                `printf "\\ntheme = '${options.themeName}'\\n" >> ${JSON.stringify(options.configFile)}`,
            ],
            cwd: projectRoot,
            expectedFiles: [options.configFile],
        },
        {
            name: "Build site",
            command: "hugo",
            args: [],
            cwd: projectRoot,
            expectedFiles: ["public/index.html"],
        },
    ];
  // Steps that run once the theme is in place.
  const buildSteps: StepDefinition[] = [
    {
      name: "Configure theme in Hugo config",
      command: "bash",
      args: [
        "-lc",
        `printf "\\ntheme = '${options.themeName}'\\n" >> ${JSON.stringify(options.configFile)}`,
      ],
      cwd: projectRoot,
      expectedFiles: [options.configFile],
    },
    {
      name: "Build site",
      command: "hugo",
      args: [],
      cwd: projectRoot,
      expectedFiles: ["public/index.html"],
    },
  ];
    try {
        console.log(`Test root: ${sandboxRoot}`);
        console.log(`Project root: ${projectRoot}`);
        console.log(
            options.themeSource === "submodule"
                ? `Theme source: submodule (${options.themeRepo})`
                : `Theme source: local (${options.themePath})`,
        );
  try {
    console.log(`Test root: ${sandboxRoot}`);
    console.log(`Project root: ${projectRoot}`);
    console.log(
      options.themeSource === "submodule"
        ? `Theme source: submodule (${options.themeRepo})`
        : `Theme source: local (${options.themePath})`,
    );
        await runSteps(setupSteps, options, projectRoot, reports);
        await installTheme(options, projectRoot, reports);
        await runSteps(buildSteps, options, projectRoot, reports);
    await runSteps(setupSteps, options, projectRoot, reports);
    await installTheme(options, projectRoot, reports);
    await runSteps(buildSteps, options, projectRoot, reports);
        const configPath = join(projectRoot, options.configFile);
        const homepagePath = join(projectRoot, "public/index.html");
        const contentPath = join(projectRoot, "content/foo.md");
        const draftOutputPath = join(projectRoot, "public", "foo", "index.html");
    const configPath = join(projectRoot, options.configFile);
    const homepagePath = join(projectRoot, "public/index.html");
    const contentPath = join(projectRoot, "content/foo.md");
    const draftOutputPath = join(projectRoot, "public", "foo", "index.html");
        console.log("\n[RUN] Strict config assertion");
        await assertThemeConfigured(configPath, options.themeName);
        console.log("[OK ] Strict config assertion");
    console.log("\n[RUN] Strict config assertion");
    await assertThemeConfigured(configPath, options.themeName);
    console.log("[OK ] Strict config assertion");
        console.log("\n[RUN] Strict homepage assertion");
        await assertHomepageLooksValid(homepagePath);
        console.log("[OK ] Strict homepage assertion");
    console.log("\n[RUN] Strict homepage assertion");
    await assertHomepageLooksValid(homepagePath);
    console.log("[OK ] Strict homepage assertion");
        console.log("\n[RUN] Create sample content");
        const createContentStep: StepDefinition = {
            name: "Create sample content",
            command: "hugo",
            args: ["new", "foo.md"],
            cwd: projectRoot,
            expectedFiles: ["content/foo.md"],
        };
        const createContentReport = await executeStep(createContentStep);
        reports.push(createContentReport);
    console.log("\n[RUN] Create sample content");
    const createContentStep: StepDefinition = {
      name: "Create sample content",
      command: "hugo",
      args: ["new", "foo.md"],
      cwd: projectRoot,
      expectedFiles: ["content/foo.md"],
    };
    const createContentReport = await executeStep(createContentStep);
    reports.push(createContentReport);
        if (options.verbose) {
            console.log(
                `      ${formatCommand(createContentStep.command, createContentStep.args)}`,
            );
            console.log(
                `[OK ] ${createContentStep.name} (${createContentReport.result.durationMs} ms, exit ${String(createContentReport.result.code)})`,
            );
    if (options.verbose) {
      console.log(
        `      ${formatCommand(createContentStep.command, createContentStep.args)}`,
      );
      console.log(
        `[OK ] ${createContentStep.name} (${createContentReport.result.durationMs} ms, exit ${String(createContentReport.result.code)})`,
      );
            const trimmedOutput = createContentReport.result.combined.trim();
            if (trimmedOutput) {
                console.log(trimmedOutput);
            }
        }
      const trimmedOutput = createContentReport.result.combined.trim();
      if (trimmedOutput) {
        console.log(trimmedOutput);
      }
    }
        console.log("\n[RUN] Replace generated content with quickstart sample");
        await replaceGeneratedContent(contentPath);
        console.log("[OK ] Replace generated content with quickstart sample");
    console.log("\n[RUN] Replace generated content with quickstart sample");
    await replaceGeneratedContent(contentPath);
    console.log("[OK ] Replace generated content with quickstart sample");
        console.log("\n[RUN] Build drafts and verify rendered draft content");
        const draftBuildStep: StepDefinition = {
            name: "Build site with drafts",
            command: "hugo",
            args: ["--buildDrafts"],
            cwd: projectRoot,
            expectedFiles: ["public/index.html", "public/foo/index.html"],
        };
        const draftBuildReport = await executeHugoBuildStep(
            draftBuildStep,
            projectRoot,
        );
        reports.push(draftBuildReport);
    console.log("\n[RUN] Build drafts and verify rendered draft content");
    const draftBuildStep: StepDefinition = {
      name: "Build site with drafts",
      command: "hugo",
      args: ["--buildDrafts"],
      cwd: projectRoot,
      expectedFiles: ["public/index.html", "public/foo/index.html"],
    };
    const draftBuildReport = await executeHugoBuildStep(
      draftBuildStep,
      projectRoot,
    );
    reports.push(draftBuildReport);
        if (options.verbose) {
            console.log(
                `      ${formatCommand(draftBuildStep.command, draftBuildStep.args)}`,
            );
            console.log(
                `[OK ] ${draftBuildStep.name} (${draftBuildReport.result.durationMs} ms, exit ${String(draftBuildReport.result.code)})`,
            );
    if (options.verbose) {
      console.log(
        `      ${formatCommand(draftBuildStep.command, draftBuildStep.args)}`,
      );
      console.log(
        `[OK ] ${draftBuildStep.name} (${draftBuildReport.result.durationMs} ms, exit ${String(draftBuildReport.result.code)})`,
      );
            const trimmedOutput = draftBuildReport.result.combined.trim();
            if (trimmedOutput) {
                console.log(trimmedOutput);
            }
        }
      const trimmedOutput = draftBuildReport.result.combined.trim();
      if (trimmedOutput) {
        console.log(trimmedOutput);
      }
    }
        const draftPageHtml = await readTextFile(draftOutputPath);
        assertDraftPageRendered(draftPageHtml);
        console.log("[OK ] Build drafts and verify rendered draft content");
    const draftPageHtml = await readTextFile(draftOutputPath);
    assertDraftPageRendered(draftPageHtml);
    console.log("[OK ] Build drafts and verify rendered draft content");
        console.log("\n[RUN] Replace root hugo.toml with quickstart config");
        await replaceHugoConfig(configPath, options.themeName);
        console.log("[OK ] Replace root hugo.toml with quickstart config");
    console.log("\n[RUN] Replace root hugo.toml with quickstart config");
    await replaceHugoConfig(configPath, options.themeName);
    console.log("[OK ] Replace root hugo.toml with quickstart config");
        console.log("\n[RUN] Build drafts and verify updated title and locale");
        const configBuildStep: StepDefinition = {
            name: "Build site with updated config and drafts",
            command: "hugo",
            args: ["--buildDrafts"],
            cwd: projectRoot,
            expectedFiles: ["public/index.html", "public/foo/index.html"],
        };
        const configBuildReport = await executeHugoBuildStep(
            configBuildStep,
            projectRoot,
        );
        reports.push(configBuildReport);
    console.log("\n[RUN] Build drafts and verify updated title and locale");
    const configBuildStep: StepDefinition = {
      name: "Build site with updated config and drafts",
      command: "hugo",
      args: ["--buildDrafts"],
      cwd: projectRoot,
      expectedFiles: ["public/index.html", "public/foo/index.html"],
    };
    const configBuildReport = await executeHugoBuildStep(
      configBuildStep,
      projectRoot,
    );
    reports.push(configBuildReport);
        if (options.verbose) {
            console.log(
                `      ${formatCommand(configBuildStep.command, configBuildStep.args)}`,
            );
            console.log(
                `[OK ] ${configBuildStep.name} (${configBuildReport.result.durationMs} ms, exit ${String(configBuildReport.result.code)})`,
            );
    if (options.verbose) {
      console.log(
        `      ${formatCommand(configBuildStep.command, configBuildStep.args)}`,
      );
      console.log(
        `[OK ] ${configBuildStep.name} (${configBuildReport.result.durationMs} ms, exit ${String(configBuildReport.result.code)})`,
      );
            const trimmedOutput = configBuildReport.result.combined.trim();
            if (trimmedOutput) {
                console.log(trimmedOutput);
            }
        }
      const trimmedOutput = configBuildReport.result.combined.trim();
      if (trimmedOutput) {
        console.log(trimmedOutput);
      }
    }
        const updatedHomepageHtml = await readTextFile(homepagePath);
        assertUpdatedConfigInOutput(updatedHomepageHtml);
        console.log("[OK ] Build drafts and verify updated title and locale");
    const updatedHomepageHtml = await readTextFile(homepagePath);
    assertUpdatedConfigInOutput(updatedHomepageHtml);
    console.log("[OK ] Build drafts and verify updated title and locale");
        console.log("\n[RUN] Production build should exclude draft content");
        const productionBuildStep: StepDefinition = {
            name: "Build production site without drafts",
            command: "hugo",
            args: [],
            cwd: projectRoot,
            expectedFiles: ["public/index.html"],
        };
        const productionBuildReport = await executeHugoBuildStep(
            productionBuildStep,
            projectRoot,
        );
        reports.push(productionBuildReport);
    console.log("\n[RUN] Production build should exclude draft content");
    const productionBuildStep: StepDefinition = {
      name: "Build production site without drafts",
      command: "hugo",
      args: [],
      cwd: projectRoot,
      expectedFiles: ["public/index.html"],
    };
    const productionBuildReport = await executeHugoBuildStep(
      productionBuildStep,
      projectRoot,
    );
    reports.push(productionBuildReport);
        if (options.verbose) {
            console.log(
                `      ${formatCommand(productionBuildStep.command, productionBuildStep.args)}`,
            );
            console.log(
                `[OK ] ${productionBuildStep.name} (${productionBuildReport.result.durationMs} ms, exit ${String(productionBuildReport.result.code)})`,
            );
    if (options.verbose) {
      console.log(
        `      ${formatCommand(productionBuildStep.command, productionBuildStep.args)}`,
      );
      console.log(
        `[OK ] ${productionBuildStep.name} (${productionBuildReport.result.durationMs} ms, exit ${String(productionBuildReport.result.code)})`,
      );
            const trimmedOutput = productionBuildReport.result.combined.trim();
            if (trimmedOutput) {
                console.log(trimmedOutput);
            }
        }
      const trimmedOutput = productionBuildReport.result.combined.trim();
      if (trimmedOutput) {
        console.log(trimmedOutput);
      }
    }
        await assertDraftHiddenInProduction(projectRoot, homepagePath);
        console.log("[OK ] Production build should exclude draft content");
    await assertDraftHiddenInProduction(projectRoot, homepagePath);
    console.log("[OK ] Production build should exclude draft content");
        console.log("\n[RUN] Configurable hero header section class (issue #504)");
        await writeHeaderSectionClassFixtures(join(projectRoot, "content"));
        const headerSectionBuildStep: StepDefinition = {
            name: "Build site with configurable header section fixtures",
            command: "hugo",
            args: [],
            cwd: projectRoot,
            expectedFiles: [
                "public/custom-header/index.html",
                "public/default-header/index.html",
            ],
        };
        const headerSectionBuildReport = await executeHugoBuildStep(
            headerSectionBuildStep,
            projectRoot,
        );
        reports.push(headerSectionBuildReport);
    console.log("\n[RUN] Configurable hero header section class (issue #504)");
    await writeHeaderSectionClassFixtures(join(projectRoot, "content"));
    const headerSectionBuildStep: StepDefinition = {
      name: "Build site with configurable header section fixtures",
      command: "hugo",
      args: [],
      cwd: projectRoot,
      expectedFiles: [
        "public/custom-header/index.html",
        "public/default-header/index.html",
      ],
    };
    const headerSectionBuildReport = await executeHugoBuildStep(
      headerSectionBuildStep,
      projectRoot,
    );
    reports.push(headerSectionBuildReport);
        if (options.verbose) {
            console.log(
                `      ${formatCommand(headerSectionBuildStep.command, headerSectionBuildStep.args)}`,
            );
            console.log(
                `[OK ] ${headerSectionBuildStep.name} (${headerSectionBuildReport.result.durationMs} ms, exit ${String(headerSectionBuildReport.result.code)})`,
            );
    if (options.verbose) {
      console.log(
        `      ${formatCommand(headerSectionBuildStep.command, headerSectionBuildStep.args)}`,
      );
      console.log(
        `[OK ] ${headerSectionBuildStep.name} (${headerSectionBuildReport.result.durationMs} ms, exit ${String(headerSectionBuildReport.result.code)})`,
      );
            const trimmedOutput = headerSectionBuildReport.result.combined.trim();
            if (trimmedOutput) {
                console.log(trimmedOutput);
            }
        }
      const trimmedOutput = headerSectionBuildReport.result.combined.trim();
      if (trimmedOutput) {
        console.log(trimmedOutput);
      }
    }
        await assertHeaderSectionClassConfigurable(projectRoot);
        console.log("[OK ] Configurable hero header section class (issue #504)");
    await assertHeaderSectionClassConfigurable(projectRoot);
    console.log("[OK ] Configurable hero header section class (issue #504)");
        console.log("\n[RUN] List page image cards and summary dates (issue #217)");
        await writeListCardFixtures(join(projectRoot, "content"));
        const cardsListPath = join(
            projectRoot,
            "public",
            "cards",
            "index.html",
        );
    console.log("\n[RUN] List page image cards and summary dates (issue #217)");
    await writeListCardFixtures(join(projectRoot, "content"));
    const cardsListPath = join(projectRoot, "public", "cards", "index.html");
        // Default configuration: no image cards, but dates show by default.
        await writeTextFile(
            configPath,
            [
                "baseURL = 'https://example.com/'",
                "title = 'Ananke Test Quickstart'",
                `theme = '${options.themeName}'`,
                "",
            ].join("\n"),
        );
        const cardsDefaultBuildStep: StepDefinition = {
            name: "Build site with default summary cards",
            command: "hugo",
            args: [],
            cwd: projectRoot,
            expectedFiles: ["public/cards/index.html"],
        };
        const cardsDefaultBuildReport = await executeHugoBuildStep(
            cardsDefaultBuildStep,
            projectRoot,
        );
        reports.push(cardsDefaultBuildReport);
        assertListCardSummaries(
            await readTextFile(cardsListPath),
            "defaults",
            { images: false, dates: true },
        );
    // Default configuration: no image cards, but dates show by default.
    await writeTextFile(
      configPath,
      [
        "baseURL = 'https://example.com/'",
        "title = 'Ananke Test Quickstart'",
        `theme = '${options.themeName}'`,
        "",
      ].join("\n"),
    );
    const cardsDefaultBuildStep: StepDefinition = {
      name: "Build site with default summary cards",
      command: "hugo",
      args: [],
      cwd: projectRoot,
      expectedFiles: ["public/cards/index.html"],
    };
    const cardsDefaultBuildReport = await executeHugoBuildStep(
      cardsDefaultBuildStep,
      projectRoot,
    );
    reports.push(cardsDefaultBuildReport);
    assertListCardSummaries(await readTextFile(cardsListPath), "defaults", {
      images: false,
      dates: true,
    });
        // Opt in to image cards and disable summary dates: images appear and the
        // dates disappear, proving the two settings are independent.
        await writeTextFile(
            configPath,
            [
                "baseURL = 'https://example.com/'",
                "title = 'Ananke Test Quickstart'",
                `theme = '${options.themeName}'`,
                "[params.ananke.pages]",
                "show_list_images = true",
                "show_date = false",
                "",
            ].join("\n"),
        );
        const cardsImagesBuildStep: StepDefinition = {
            name: "Build site with image cards and dates disabled",
            command: "hugo",
            args: [],
            cwd: projectRoot,
            expectedFiles: ["public/cards/index.html"],
        };
        const cardsImagesBuildReport = await executeHugoBuildStep(
            cardsImagesBuildStep,
            projectRoot,
        );
        reports.push(cardsImagesBuildReport);
        assertListCardSummaries(
            await readTextFile(cardsListPath),
            "image cards with dates disabled",
            { images: true, dates: false },
        );
    // Opt in to image cards and disable summary dates: images appear and the
    // dates disappear, proving the two settings are independent.
    await writeTextFile(
      configPath,
      [
        "baseURL = 'https://example.com/'",
        "title = 'Ananke Test Quickstart'",
        `theme = '${options.themeName}'`,
        "[params.ananke.pages]",
        "show_list_images = true",
        "show_date = false",
        "",
      ].join("\n"),
    );
    const cardsImagesBuildStep: StepDefinition = {
      name: "Build site with image cards and dates disabled",
      command: "hugo",
      args: [],
      cwd: projectRoot,
      expectedFiles: ["public/cards/index.html"],
    };
    const cardsImagesBuildReport = await executeHugoBuildStep(
      cardsImagesBuildStep,
      projectRoot,
    );
    reports.push(cardsImagesBuildReport);
    assertListCardSummaries(
      await readTextFile(cardsListPath),
      "image cards with dates disabled",
      { images: true, dates: false },
    );
        console.log("[OK ] List page image cards and summary dates (issue #217)");
    console.log("[OK ] List page image cards and summary dates (issue #217)");
        console.log("\nResult: PASS");
    console.log("\nResult: PASS");
        if (options.keepOnSuccess) {
            console.log(`Keeping successful test directory: ${projectRoot}`);
        } else {
            await rm(sandboxRoot, { recursive: true, force: true });
            console.log(`Deleted successful test directory: ${sandboxRoot}`);
        }
    if (options.keepOnSuccess) {
      console.log(`Keeping successful test directory: ${projectRoot}`);
    } else {
      await rm(sandboxRoot, { recursive: true, force: true });
      console.log(`Deleted successful test directory: ${sandboxRoot}`);
    }
        return 0;
    } catch (error: unknown) {
        const message = error instanceof Error ? error.message : "Unknown error";
    return 0;
  } catch (error: unknown) {
    const message = error instanceof Error ? error.message : "Unknown error";
        console.error("\nResult: FAIL");
        console.error(message);
    console.error("\nResult: FAIL");
    console.error(message);
        if (reports.length > 0) {
            console.error("\nCompleted command steps before failure:");
            for (const report of reports) {
                console.error(`- ${report.step}`);
            }
        }
    if (reports.length > 0) {
      console.error("\nCompleted command steps before failure:");
      for (const report of reports) {
        console.error(`- ${report.step}`);
      }
    }
        if (options.keepOnFailure) {
            console.error(
                `\nKept failing test directory for inspection: ${projectRoot}`,
            );
        } else {
            await rm(sandboxRoot, { recursive: true, force: true });
            console.error(`\nDeleted failing test directory: ${sandboxRoot}`);
        }
    if (options.keepOnFailure) {
      console.error(
        `\nKept failing test directory for inspection: ${projectRoot}`,
      );
    } else {
      await rm(sandboxRoot, { recursive: true, force: true });
      console.error(`\nDeleted failing test directory: ${sandboxRoot}`);
    }
        return 1;
    }
    return 1;
  }
}
/**
 * Main entry point.
 */
async function main(): Promise<void> {
    try {
        const options = parseArgs(process.argv.slice(2));
        const exitCode = await runRoutine(options);
        process.exit(exitCode);
    } catch (error: unknown) {
        const message =
            error instanceof Error ? error.message : "Unknown fatal error";
        console.error(`Fatal error: ${message}`);
        process.exit(1);
    }
  try {
    const options = parseArgs(process.argv.slice(2));
    const exitCode = await runRoutine(options);
    process.exit(exitCode);
  } catch (error: unknown) {
    const message =
      error instanceof Error ? error.message : "Unknown fatal error";
    console.error(`Fatal error: ${message}`);
    process.exit(1);
  }
}
await main();
tests/coverage-gate.mjs
@@ -25,43 +25,43 @@
/** 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;
  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$/, "")),
    );
  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 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")) || {};
@@ -72,32 +72,32 @@
const errors = [];
for (const p of params) {
    if (!catParams.has(p))
        errors.push(`param '${p}' has no test in tests/catalog.yaml`);
  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`);
  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`);
  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`);
  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.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.`,
  `Coverage gate passed: ${params.size} ananke param(s), ${shortcodes.size} shortcode(s) all have tests.`,
);
tests/e2e/accessibility.spec.ts
@@ -9,13 +9,13 @@
 * 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",
  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"];
@@ -27,21 +27,21 @@
 * start passing and Playwright will flag the stale annotation for removal.
 */
const KNOWN_FAILURES = new Set([
    "section list",
    "single page",
    "taxonomy terms",
    "taxonomy term",
  "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([]);
    });
  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
@@ -3,49 +3,49 @@
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("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("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);
  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();
    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");
    // 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");
    });
    // 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.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",
        );
    });
  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/support/dev-server.mjs
@@ -14,49 +14,49 @@
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",
  ".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 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);
  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}/`);
  console.log(`Fixture site served at http://localhost:${PORT}/`);
});
tests/support/prepare-site.mjs
@@ -20,36 +20,36 @@
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;
  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();
  buildFixtureSite();
}
tsconfig.json
@@ -1,29 +1,11 @@
{
    "compilerOptions": {
        "module": "nodenext",
        "target": "esnext",
        "types": [
            "node"
        ],
        "sourceMap": true,
        "declaration": true,
        "declarationMap": true,
        "noUncheckedIndexedAccess": true,
        "exactOptionalPropertyTypes": true,
        "strict": true,
        "jsx": "react-jsx",
        "verbatimModuleSyntax": true,
        "isolatedModules": true,
        "noUncheckedSideEffectImports": true,
        "moduleDetection": "force",
        "skipLibCheck": true,
        "noImplicitOverride": true,
        "noPropertyAccessFromIndexSignature": true,
        "useUnknownInCatchVariables": true,
        "noFallthroughCasesInSwitch": true,
    },
    "include": [
        "**/*.ts",
        "scripts/**/*"
    ]
}
  "extends": "@dnbhq/tsconfig/cli",
  "compilerOptions": {
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "sourceMap": true,
    "declaration": true,
    "declarationMap": true,
    "jsx": "react-jsx"
  },
  "include": ["**/*.ts", "scripts/**/*"]
}