diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 34e3682..f6d6a7a 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -45,7 +45,10 @@ jobs:
- name: Run Linting
run: npm run lint
-
+
+ - name: Run Unit Tests
+ run: npm run test:unit
+
- name: Run Spell Check (Source)
run: npm run spellcheck
diff --git a/package-lock.json b/package-lock.json
index b7c2901..4edcae1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -47,7 +47,8 @@
"@types/node": "^24.12.2",
"cspell": "^10.0.0",
"markdownlint-cli2": "^0.22.0",
- "prettier": "^3.8.3"
+ "prettier": "^3.8.3",
+ "vitest": "^4.1.9"
},
"engines": {
"node": ">=24.0.0"
@@ -3227,6 +3228,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
@@ -3280,6 +3288,17 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -3289,6 +3308,13 @@
"@types/ms": "*"
}
},
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -3662,6 +3688,119 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/expect": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
+ "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
+ "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.9",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
+ "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
+ "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.9",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
+ "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
+ "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
+ "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.9",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/@volar/language-core": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
@@ -3928,6 +4067,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/astring": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz",
@@ -4536,6 +4685,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@@ -5942,6 +6101,16 @@
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/expressive-code": {
"version": "0.41.7",
"resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.41.7.tgz",
@@ -8710,6 +8879,20 @@
"node": ">= 6"
}
},
+ "node_modules/obug": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
+ "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/ofetch": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz",
@@ -10023,6 +10206,13 @@
"@types/hast": "^3.0.4"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -10101,6 +10291,20 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/stream-replace-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz",
@@ -10447,6 +10651,13 @@
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tinyexec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
@@ -10472,6 +10683,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -11584,6 +11805,103 @@
}
}
},
+ "node_modules/vitest": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
+ "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.9",
+ "@vitest/mocker": "4.1.9",
+ "@vitest/pretty-format": "4.1.9",
+ "@vitest/runner": "4.1.9",
+ "@vitest/snapshot": "4.1.9",
+ "@vitest/spy": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.9",
+ "@vitest/browser-preview": "4.1.9",
+ "@vitest/browser-webdriverio": "4.1.9",
+ "@vitest/coverage-istanbul": "4.1.9",
+ "@vitest/coverage-v8": "4.1.9",
+ "@vitest/ui": "4.1.9",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/es-module-lexer": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
+ "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/volar-service-css": {
"version": "0.0.70",
"resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.70.tgz",
@@ -11871,6 +12189,23 @@
"node": ">=4"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/widest-line": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz",
diff --git a/package.json b/package.json
index 7b272a2..dee19d2 100644
--- a/package.json
+++ b/package.json
@@ -23,8 +23,9 @@
"spellcheck:all": "npm run spellcheck && npm run build && npm run spellcheck:html",
"validate:links": "node scripts/validate-links.js",
"validate:all": "npm run lint && npm run spellcheck && npm run build && npm run spellcheck:html && npm run validate:links",
- "test:ci": "npm run lint && npm run spellcheck && npm run build && npm run spellcheck:html && npm run validate:links",
- "test:ci:verbose": "echo '🔍 Running CI validation locally...' && npm run lint && echo '✓ Linting passed' && npm run spellcheck && echo '✓ Source spell check passed' && npm run build && echo '✓ Build succeeded' && npm run spellcheck:html && echo '✓ HTML spell check passed' && npm run validate:links && echo '✓ Link validation passed' && echo '✅ All CI checks passed!'",
+ "test:unit": "vitest run",
+ "test:ci": "npm run lint && npm run test:unit && npm run spellcheck && npm run build && npm run spellcheck:html && npm run validate:links",
+ "test:ci:verbose": "echo '🔍 Running CI validation locally...' && npm run lint && echo '✓ Linting passed' && npm run test:unit && echo '✓ Unit tests passed' && npm run spellcheck && echo '✓ Source spell check passed' && npm run build && echo '✓ Build succeeded' && npm run spellcheck:html && echo '✓ HTML spell check passed' && npm run validate:links && echo '✓ Link validation passed' && echo '✅ All CI checks passed!'",
"qa": "playwright test --ignore-snapshots",
"qa:headed": "playwright test --headed --ignore-snapshots",
"qa:ui": "playwright test --ui",
@@ -72,7 +73,8 @@
"@types/node": "^24.12.2",
"cspell": "^10.0.0",
"markdownlint-cli2": "^0.22.0",
- "prettier": "^3.8.3"
+ "prettier": "^3.8.3",
+ "vitest": "^4.1.9"
},
"overrides": {
"@astrojs/check": {
diff --git a/src/components/CallToAction.astro b/src/components/CallToAction.astro
deleted file mode 100644
index 5b96da2..0000000
--- a/src/components/CallToAction.astro
+++ /dev/null
@@ -1,86 +0,0 @@
----
-import { Icon } from 'astro-icon/components'
-
-/**
- * CallToAction Component
- *
- * @description A prominent call-to-action section with a title and link
- */
-interface Props {
- /**
- * The title text to display
- * @default "Get this theme on GitHub"
- */
- title?: string
- /**
- * The URL the button should link to
- * @default "https://github.com/incluud/accessible-astro-starter"
- */
- link?: string
- /**
- * The text to display on the button
- * @default "Get Started"
- */
- linkText?: string
-}
-
-const {
- title = 'Get this theme on GitHub',
- link = 'https://github.com/incluud/accessible-astro-starter',
- linkText = 'Use this theme',
-} = Astro.props
----
-
-
-
-
diff --git a/src/content/config.ts b/src/content/config.ts
index 7beacc7..62a2bf7 100644
--- a/src/content/config.ts
+++ b/src/content/config.ts
@@ -11,7 +11,7 @@ const blog = defineCollection({
// OpenGraph overrides
ogTitle: z.string().optional(),
ogDescription: z.string().optional(),
- ogImage: z.string().optional(),
+ ogImage: z.string().url().optional(),
ogImageAlt: z.string().optional(),
noOgImage: z.boolean().optional(),
modifiedDate: z.coerce.date().optional()
@@ -32,7 +32,7 @@ const briefs = defineCollection({
// OpenGraph overrides
ogTitle: z.string().optional(),
ogDescription: z.string().optional(),
- ogImage: z.string().optional(),
+ ogImage: z.string().url().optional(),
ogImageAlt: z.string().optional(),
noOgImage: z.boolean().optional(),
modifiedDate: z.coerce.date().optional()
@@ -50,12 +50,12 @@ const projects = defineCollection({
description: z.string(),
date: z.coerce.date(),
draft: z.boolean().optional(),
- demoURL: z.string().optional(),
- repoURL: z.string().optional(),
+ demoURL: z.string().url().optional(),
+ repoURL: z.string().url().optional(),
// OpenGraph overrides
ogTitle: z.string().optional(),
ogDescription: z.string().optional(),
- ogImage: z.string().optional(),
+ ogImage: z.string().url().optional(),
ogImageAlt: z.string().optional(),
noOgImage: z.boolean().optional(),
modifiedDate: z.coerce.date().optional()
diff --git a/src/lib/category.ts b/src/lib/category.ts
index bde4f33..5aabc03 100644
Binary files a/src/lib/category.ts and b/src/lib/category.ts differ
diff --git a/src/lib/collections.ts b/src/lib/collections.ts
new file mode 100644
index 0000000..c91cf01
--- /dev/null
+++ b/src/lib/collections.ts
@@ -0,0 +1,30 @@
+import { getCollection, type CollectionEntry, type CollectionKey } from "astro:content";
+
+/**
+ * Drop draft entries from a list of content entries.
+ */
+export function published(entries: readonly T[]): T[] {
+ return entries.filter((entry) => !entry.data.draft);
+}
+
+/**
+ * Return a new array sorted by `data.date`, newest first.
+ */
+export function byDateDesc(entries: readonly T[]): T[] {
+ return [...entries].sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
+}
+
+/**
+ * Fetch a content collection's published entries, sorted newest-first.
+ *
+ * Centralizes the draft-filter + date-desc-sort pipeline that every list
+ * page and `getStaticPaths` block would otherwise repeat. Pass `limit` to
+ * cap the result (e.g. homepage previews).
+ */
+export async function getPublishedCollection(
+ collection: C,
+ limit?: number
+): Promise[]> {
+ const entries = byDateDesc(published(await getCollection(collection)));
+ return limit === undefined ? entries : entries.slice(0, limit);
+}
diff --git a/src/lib/contentCardHelpers.ts b/src/lib/contentCardHelpers.ts
index a474d8d..25c5db6 100644
--- a/src/lib/contentCardHelpers.ts
+++ b/src/lib/contentCardHelpers.ts
@@ -9,9 +9,13 @@ type CardOptions = {
};
/**
- * Transform a blog entry into ContentCard props
+ * Shared mapping for blog/project entries, which produce identical card props.
+ * Briefs are intentionally excluded because they add category-prefix behavior.
*/
-export function getBlogCardProps(entry: CollectionEntry<"blog">, options?: CardOptions) {
+function getStandardCardProps(
+ entry: CollectionEntry<"blog"> | CollectionEntry<"projects">,
+ options?: CardOptions
+) {
const displayTitle = entry.data.cardTitle || entry.data.title;
return {
@@ -24,18 +28,17 @@ export function getBlogCardProps(entry: CollectionEntry<"blog">, options?: CardO
}
/**
- * Transform a project entry into ContentCard props
+ * Transform a blog entry into ContentCard props
*/
-export function getProjectCardProps(entry: CollectionEntry<"blog"> | CollectionEntry<"projects">, options?: CardOptions) {
- const displayTitle = entry.data.cardTitle || entry.data.title;
+export function getBlogCardProps(entry: CollectionEntry<"blog">, options?: CardOptions) {
+ return getStandardCardProps(entry, options);
+}
- return {
- title: displayTitle,
- subtitle: entry.data.description,
- link: `/${entry.collection}/${entry.slug}`,
- ...(options?.maxLines !== undefined && { maxLines: options.maxLines }),
- ...(options?.headingLevel !== undefined && { headingLevel: options.headingLevel }),
- };
+/**
+ * Transform a project entry into ContentCard props
+ */
+export function getProjectCardProps(entry: CollectionEntry<"projects">, options?: CardOptions) {
+ return getStandardCardProps(entry, options);
}
/**
diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts
new file mode 100644
index 0000000..2422e47
--- /dev/null
+++ b/src/lib/utils.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+import { dateRange, readingTime } from "./utils";
+
+/** Build a string containing exactly `count` whitespace-separated words. */
+function words(count: number): string {
+ return Array.from({ length: count }, () => "word").join(" ");
+}
+
+describe("readingTime", () => {
+ // Standard formula is `max(1, ceil(wordCount / 200))`: a short post is
+ // always at least "1 min read", and there is no blanket extra minute.
+ it.each([
+ [0, "1 min read"],
+ [1, "1 min read"],
+ [199, "1 min read"],
+ [200, "1 min read"],
+ [201, "2 min read"],
+ [399, "2 min read"],
+ [400, "2 min read"],
+ [401, "3 min read"],
+ ])("reports %i words as %s", (count, expected) => {
+ expect(readingTime(words(count))).toBe(expected);
+ });
+
+ it("normalizes empty content to a 1 min read", () => {
+ expect(readingTime("")).toBe("1 min read");
+ expect(readingTime(" \n\t ")).toBe("1 min read");
+ });
+
+ it("does not count HTML tags as words", () => {
+ expect(readingTime("hello world
")).toBe("1 min read");
+ expect(readingTime(`${words(200)}
`)).toBe("1 min read");
+ });
+
+ it("rounds partial minutes up", () => {
+ expect(readingTime(words(1000))).toBe("5 min read");
+ expect(readingTime(words(1001))).toBe("6 min read");
+ });
+});
+
+describe("dateRange", () => {
+ // Use the local Date(year, monthIndex, day) constructor so month/year are
+ // timezone-independent.
+ const start = new Date(2020, 0, 15); // Jan 2020
+
+ it("formats a closed Date-to-Date range", () => {
+ expect(dateRange(start, new Date(2021, 2, 1))).toBe("Jan 2020 - Mar 2021");
+ });
+
+ it("defaults an omitted end date to Present", () => {
+ expect(dateRange(start)).toBe("Jan 2020 - Present");
+ });
+
+ it("uses a provided string end label verbatim", () => {
+ expect(dateRange(start, "Present")).toBe("Jan 2020 - Present");
+ expect(dateRange(start, "2024")).toBe("Jan 2020 - 2024");
+ });
+
+ it("never emits 'undefined' fragments for any input form", () => {
+ expect(dateRange(start)).not.toContain("undefined");
+ expect(dateRange(start, "Now")).not.toContain("undefined");
+ expect(dateRange(start, new Date(2022, 5, 1))).not.toContain("undefined");
+ });
+});
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 03b4e2f..ba9bfa6 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -13,28 +13,40 @@ export function formatDate(date: Date) {
}).format(date);
}
-export function readingTime(html: string) {
- const textOnly = html.replace(/<[^>]+>/g, "");
- const wordCount = textOnly.split(/\s+/).length;
- const readingTimeMinutes = ((wordCount / 200) + 1).toFixed();
- return `${readingTimeMinutes} min read`;
+const WORDS_PER_MINUTE = 200;
+
+export function readingTime(html: string): string {
+ const textOnly = html.replace(/<[^>]+>/g, " ");
+ const wordCount = textOnly.split(/\s+/).filter(Boolean).length;
+ const minutes = Math.max(1, Math.ceil(wordCount / WORDS_PER_MINUTE));
+ return `${minutes} min read`;
+}
+
+function formatMonthYear(date: Date): string {
+ const month = date.toLocaleString("en-US", { month: "short" });
+ return `${month} ${date.getFullYear()}`;
}
+/**
+ * Format a date range as `MMM YYYY - MMM YYYY`.
+ *
+ * `endDate` may be:
+ * - a `Date`, formatted the same way as the start (`MMM YYYY`);
+ * - a string, used verbatim as the end label (e.g. `"Present"`);
+ * - omitted, in which case the range is treated as open-ended and the
+ * end label defaults to `"Present"`.
+ */
export function dateRange(startDate: Date, endDate?: Date | string): string {
- const startMonth = startDate.toLocaleString("default", { month: "short" });
- const startYear = startDate.getFullYear().toString();
- let endMonth;
- let endYear;
-
- if (endDate) {
- if (typeof endDate === "string") {
- endMonth = "";
- endYear = endDate;
- } else {
- endMonth = endDate.toLocaleString("default", { month: "short" });
- endYear = endDate.getFullYear().toString();
- }
+ const start = formatMonthYear(startDate);
+
+ let end: string;
+ if (endDate === undefined) {
+ end = "Present";
+ } else if (typeof endDate === "string") {
+ end = endDate;
+ } else {
+ end = formatMonthYear(endDate);
}
- return `${startMonth}${startYear} - ${endMonth}${endYear}`;
+ return `${start} - ${end}`;
}
\ No newline at end of file
diff --git a/src/pages/blog/[...slug].astro b/src/pages/blog/[...slug].astro
index e048cdd..8650905 100644
--- a/src/pages/blog/[...slug].astro
+++ b/src/pages/blog/[...slug].astro
@@ -1,17 +1,16 @@
---
-import { type CollectionEntry, getCollection } from "astro:content";
+import type { CollectionEntry } from "astro:content";
import PageLayout from "@layouts/PageLayout.astro";
import Container from "@components/Container.astro";
import FormattedDate from "@components/FormattedDate.astro";
import { readingTime } from "@lib/utils";
+import { getPublishedCollection } from "@lib/collections";
import BackToPrev from "@components/BackToPrev.astro";
import { getPostOGData } from "@lib/opengraph";
import { renderInlineMarkdown } from "@lib/markdown";
export async function getStaticPaths() {
- const posts = (await getCollection("blog"))
- .filter(post => !post.data.draft)
- .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
+ const posts = await getPublishedCollection("blog");
return posts.map((post) => ({
params: { slug: post.slug },
props: post,
diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro
index c928dae..81b3046 100644
--- a/src/pages/blog/index.astro
+++ b/src/pages/blog/index.astro
@@ -1,15 +1,14 @@
---
-import { type CollectionEntry, getCollection } from "astro:content";
+import type { CollectionEntry } from "astro:content";
import PageLayout from "@layouts/PageLayout.astro";
import Container from "@components/Container.astro";
import ContentCard from "@components/ContentCard.astro";
import { getBlogCardProps } from "@lib/contentCardHelpers";
+import { getPublishedCollection } from "@lib/collections";
import { BLOG } from "@consts";
import { getListOGData } from "@lib/opengraph";
-const data = (await getCollection("blog"))
- .filter(post => !post.data.draft)
- .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
+const data = await getPublishedCollection("blog");
type Acc = {
[year: string]: CollectionEntry<"blog">[];
diff --git a/src/pages/briefs/[...slug].astro b/src/pages/briefs/[...slug].astro
index 9db35ac..90de73e 100644
--- a/src/pages/briefs/[...slug].astro
+++ b/src/pages/briefs/[...slug].astro
@@ -1,17 +1,16 @@
---
-import { type CollectionEntry, getCollection } from "astro:content";
+import type { CollectionEntry } from "astro:content";
import PageLayout from "@layouts/PageLayout.astro";
import Container from "@components/Container.astro";
import FormattedDate from "@components/FormattedDate.astro";
import BackToPrev from "@components/BackToPrev.astro";
import { extractCategoryFromSlug, getCategory } from "@lib/category";
+import { getPublishedCollection } from "@lib/collections";
import { getBriefOGData } from "@lib/opengraph";
import { renderInlineMarkdown } from "@lib/markdown";
export async function getStaticPaths() {
- const briefs = (await getCollection("briefs"))
- .filter(brief => !brief.data.draft)
- .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
+ const briefs = await getPublishedCollection("briefs");
return briefs.map((brief) => ({
params: { slug: brief.slug },
props: brief,
diff --git a/src/pages/briefs/[category].astro b/src/pages/briefs/[category].astro
index 544e52a..b972270 100644
--- a/src/pages/briefs/[category].astro
+++ b/src/pages/briefs/[category].astro
@@ -5,13 +5,13 @@ import Container from "@components/Container.astro";
import ContentCard from "@components/ContentCard.astro";
import { getBriefCardProps } from "@lib/contentCardHelpers";
import { getCategory, extractCategoryFromSlug } from "@lib/category";
+import { byDateDesc, published } from "@lib/collections";
import BackToPrev from "@components/BackToPrev.astro";
import { getListOGData } from "@lib/opengraph";
export async function getStaticPaths() {
- const allBriefs = (await getCollection("briefs"))
- .filter(brief => !brief.data.draft);
-
+ const allBriefs = published(await getCollection("briefs"));
+
// Get unique categories from brief slugs
const categories = new Set();
allBriefs.forEach(brief => {
@@ -20,15 +20,15 @@ export async function getStaticPaths() {
categories.add(category);
}
});
-
+
// Create paths for each category
return Array.from(categories).map(category => ({
params: { category },
props: {
category,
- briefs: allBriefs
- .filter(brief => extractCategoryFromSlug(brief.slug) === category)
- .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
+ briefs: byDateDesc(
+ allBriefs.filter(brief => extractCategoryFromSlug(brief.slug) === category)
+ )
}
}));
}
@@ -42,13 +42,6 @@ const { category: categorySlug, briefs } = Astro.props;
const categoryPath = `src/content/briefs/${categorySlug}`;
const category = getCategory(categorySlug, categoryPath);
-const renderedBriefs = await Promise.all(
- briefs.map(async (item) => {
- const { Content } = await item.render();
- return { ...item, Content };
- })
-);
-
const ogData = getListOGData(
`${category.displayName} Briefs`,
category.description || `Briefs about ${category.displayName.toLowerCase()}`,
@@ -78,7 +71,7 @@ const ogData = getListOGData(
)}
- {renderedBriefs.map((brief) => (
+ {briefs.map((brief) => (
-
diff --git a/src/pages/briefs/index.astro b/src/pages/briefs/index.astro
index 5fbfdf7..3cdb54a 100644
--- a/src/pages/briefs/index.astro
+++ b/src/pages/briefs/index.astro
@@ -1,24 +1,16 @@
---
-import { type CollectionEntry, getCollection } from "astro:content";
+import type { CollectionEntry } from "astro:content";
import PageLayout from "@layouts/PageLayout.astro";
import Container from "@components/Container.astro";
import ContentCard from "@components/ContentCard.astro";
import Link from "@components/Link.astro";
import { getBriefCardProps } from "@lib/contentCardHelpers";
import { extractCategoryFromSlug, getCategory } from "@lib/category";
+import { getPublishedCollection } from "@lib/collections";
import { BRIEFS } from "@consts";
import { getListOGData } from "@lib/opengraph";
-const collection = (await getCollection("briefs"))
- .filter(brief => !brief.data.draft)
- .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
-
-const briefs = await Promise.all(
- collection.map(async (item) => {
- const { Content } = await item.render();
- return { ...item, Content };
- })
-);
+const collection = await getPublishedCollection("briefs");
type BriefList = CollectionEntry<"briefs">[];
type Brief = CollectionEntry<"briefs">;
@@ -31,7 +23,7 @@ type BriefsByCategory = {
}
// Group briefs by category
-const briefs_by_category: BriefsByCategory = briefs.reduce((acc: BriefsByCategory, brief) => {
+const briefs_by_category: BriefsByCategory = collection.reduce((acc: BriefsByCategory, brief) => {
const categorySlug = extractCategoryFromSlug(brief.slug);
const categoryKey = categorySlug || 'uncategorized';
diff --git a/src/pages/index.astro b/src/pages/index.astro
index f65cf5a..199d9c3 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,35 +1,16 @@
---
-import { getCollection } from "astro:content";
import Container from "@components/Container.astro";
import PageLayout from "@layouts/PageLayout.astro";
import ContentCard from "@components/ContentCard.astro";
import { getBlogCardProps, getBriefCardProps, getProjectCardProps } from "@lib/contentCardHelpers";
import Link from "@components/Link.astro";
-// import { dateRange } from "@lib/utils";
+import { getPublishedCollection } from "@lib/collections";
import { SITE, HOME, SOCIALS } from "@consts";
import { getHomeOGData } from "@lib/opengraph";
-const blog = (await getCollection("blog"))
- .filter(post => !post.data.draft)
- .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
- .slice(0,SITE.NUM_POSTS_ON_HOMEPAGE);
-
-const projects = (await getCollection("projects"))
- .filter(project => !project.data.draft)
- .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
- .slice(0,SITE.NUM_PROJECTS_ON_HOMEPAGE);
-
-const allwork = (await getCollection("briefs"))
- .filter(brief => !brief.data.draft)
- .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
- .slice(0,SITE.NUM_BRIEFS_ON_HOMEPAGE);
-
-const briefs = await Promise.all(
- allwork.map(async (item) => {
- const { Content } = await item.render();
- return { ...item, Content };
- })
-);
+const blog = await getPublishedCollection("blog", SITE.NUM_POSTS_ON_HOMEPAGE);
+const projects = await getPublishedCollection("projects", SITE.NUM_PROJECTS_ON_HOMEPAGE);
+const briefs = await getPublishedCollection("briefs", SITE.NUM_BRIEFS_ON_HOMEPAGE);
const ogData = getHomeOGData(
Astro.url.toString(),
diff --git a/src/pages/projects/[...slug].astro b/src/pages/projects/[...slug].astro
index 2e74f7c..6d1863c 100644
--- a/src/pages/projects/[...slug].astro
+++ b/src/pages/projects/[...slug].astro
@@ -1,18 +1,17 @@
---
-import { type CollectionEntry, getCollection } from "astro:content";
+import type { CollectionEntry } from "astro:content";
import PageLayout from "@layouts/PageLayout.astro";
import Container from "@components/Container.astro";
import FormattedDate from "@components/FormattedDate.astro";
import { readingTime } from "@lib/utils";
+import { getPublishedCollection } from "@lib/collections";
import BackToPrev from "@components/BackToPrev.astro";
import Link from "@components/Link.astro";
import { getProjectOGData } from "@lib/opengraph";
import { renderInlineMarkdown } from "@lib/markdown";
export async function getStaticPaths() {
- const projects = (await getCollection("projects"))
- .filter(post => !post.data.draft)
- .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
+ const projects = await getPublishedCollection("projects");
return projects.map((project) => ({
params: { slug: project.slug },
props: project,
diff --git a/src/pages/projects/index.astro b/src/pages/projects/index.astro
index 419becc..8042889 100644
--- a/src/pages/projects/index.astro
+++ b/src/pages/projects/index.astro
@@ -1,15 +1,13 @@
---
-import { getCollection } from "astro:content";
import PageLayout from "@layouts/PageLayout.astro";
import Container from "@components/Container.astro";
import ContentCard from "@components/ContentCard.astro";
import { getProjectCardProps } from "@lib/contentCardHelpers";
+import { getPublishedCollection } from "@lib/collections";
import { PROJECTS } from "@consts";
import { getListOGData } from "@lib/opengraph";
-const projects = (await getCollection("projects"))
- .filter(project => !project.data.draft)
- .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
+const projects = await getPublishedCollection("projects");
const ogData = getListOGData(
PROJECTS.TITLE,
diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts
index 8cfeb22..157a9fe 100644
--- a/src/pages/rss.xml.ts
+++ b/src/pages/rss.xml.ts
@@ -1,5 +1,6 @@
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
+import { byDateDesc, published } from "@lib/collections";
import { HOME } from "@consts";
type Context = {
@@ -7,14 +8,10 @@ type Context = {
}
export async function GET(context: Context) {
- const blog = (await getCollection("blog"))
- .filter(post => !post.data.draft);
+ const blog = published(await getCollection("blog"));
+ const projects = published(await getCollection("projects"));
- const projects = (await getCollection("projects"))
- .filter(project => !project.data.draft);
-
- const items = [...blog, ...projects]
- .sort((a, b) => new Date(b.data.date).valueOf() - new Date(a.data.date).valueOf());
+ const items = byDateDesc([...blog, ...projects]);
return rss({
title: HOME.TITLE,
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..0292c80
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "vitest/config";
+
+// Unit tests live next to the code they cover as `*.test.ts` under `src/`.
+// The `tests/` directory is reserved for Playwright (`*.spec.ts`) QA suites,
+// so we scope Vitest to `src/` to avoid the two runners fighting over files.
+export default defineConfig({
+ test: {
+ include: ["src/**/*.test.ts"],
+ },
+});