build(deps): onboard dnbhq configurations and linting fixes
21 files modified
3 files deleted
5 files added
| | |
| | | { |
| | | "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" |
| | | } |
| | | ] |
| | | } |
| | |
| | | { |
| | | "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": [] |
| | | } |
| | |
| | | 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. |
| | |
| | | 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); |
| | | |
| | | /** |
| | |
| | | */ |
| | | 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 []; |
| | |
| | | */ |
| | | 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}`); |
| | |
| | | |
| | | 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; |
| | | } |
| | | } |
| | |
| | | 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 }), |
| | | }); |
| | | |
| | |
| | | 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); |
| | | } |
| | | } |
| | | |
| | |
| | | * @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] || "", |
| | | ); |
| | | } |
| | | |
| | | /** |
| | |
| | | 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); |
| | | } |
| | | } |
| | | |
| | |
| | | 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; |
| New file |
| | |
| | | --- |
| | | 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. |
| New file |
| | |
| | | --- |
| | | 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. |
| | |
| | | { |
| | | "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 |
| | | } |
| | |
| | | { |
| | | "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" |
| | | ] |
| | | } |
| | | } |
| | |
| | | pre, .pre { |
| | | pre, |
| | | .pre { |
| | | overflow-x: auto; |
| | | overflow-y: hidden; |
| | | } |
| | |
| | | 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. */ |
| | |
| | | .code-block .code-copy { |
| | | transition: none; |
| | | } |
| | | } |
| | | } |
| | |
| | | |
| | | .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; |
| | |
| | | .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; |
| | | } |
| | |
| | | |
| | | .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; |
| | | } |
| New file |
| | |
| | | { |
| | | "$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" |
| | | ] |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | |
| | | { |
| | | "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" |
| | | } |
| | | } |
| | |
| | | * 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, |
| | | }, |
| | | }); |
| New file |
| | |
| | | { |
| | | "$schema": "https://docs.renovatebot.com/renovate-schema.json", |
| | | "extends": ["github>dnbhq/renovate-config"], |
| | | "assignees": ["davidsneighbour"], |
| | | "baseBranches": ["development"], |
| | | "labels": ["type:dependencies"], |
| | | } |
| | |
| | | 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; |
| | |
| | | * 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] |
| | | |
| | |
| | | * updated local file was replaced because --apply was used |
| | | * error processing failed |
| | | `.trim(), |
| | | ); |
| | | ); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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; |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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)}`; |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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)); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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; |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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(); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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, |
| | | }; |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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, |
| | | }; |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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"; |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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}`); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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, |
| | | }; |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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}`); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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"); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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); |
| | | }); |
| | |
| | | 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"; |
| | |
| | | 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[]; |
| | | } |
| | | |
| | | /** |
| | |
| | | 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] |
| | | |
| | |
| | | --quiet Reduce step logging |
| | | --help Show this help |
| | | `.trim(), |
| | | ); |
| | | ); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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; |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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(" "); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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, |
| | | }); |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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; |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | * @returns File contents. |
| | | */ |
| | | async function readTextFile(filePath: string): Promise<string> { |
| | | return readFile(filePath, "utf8"); |
| | | return readFile(filePath, "utf8"); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @param content File contents. |
| | | */ |
| | | async function writeTextFile(filePath: string, content: string): Promise<void> { |
| | | await writeFile(filePath, content, "utf8"); |
| | | await writeFile(filePath, content, "utf8"); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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 }); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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, |
| | | }; |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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; |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @returns Escaped string. |
| | | */ |
| | | function escapeRegExp(value: string): string { |
| | | return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| | | return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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"), |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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), |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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"), |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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]; |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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"), |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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"), |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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"), |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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"), |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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"), |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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"; |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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")); |
| | | }, |
| | | }); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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)`, |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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(); |
| | |
| | | |
| | | /** 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")) || {}; |
| | |
| | | |
| | | 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.`, |
| | | ); |
| | |
| | | * 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"]; |
| | |
| | | * 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([]); |
| | | }); |
| | | } |
| | |
| | | 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", |
| | | ); |
| | | }); |
| | | }); |
| | |
| | | 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}/`); |
| | | }); |
| | |
| | | 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(); |
| | | } |
| | |
| | | { |
| | | "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/**/*"] |
| | | } |