From 7426339d9583a6de6dc461e5d23c53d0c730bfc7 Mon Sep 17 00:00:00 2001 From: plx Date: Sat, 20 Jun 2026 04:44:39 -0500 Subject: [PATCH 1/8] perf: avoid unnecessary content rendering on list pages (#31) The home page, briefs index, and per-category brief pages each called `item.render()` for every entry and threaded the resulting `Content` component through, even though those list views only render card data derived from frontmatter (title/description/link). The `` component was never used, so every render did wasted markdown/MDX processing at build time. Drop the `Promise.all(... item.render())` passes and feed the collection entries directly into the card helpers. Sort/filter/slice behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pages/briefs/[category].astro | 9 +-------- src/pages/briefs/index.astro | 9 +-------- src/pages/index.astro | 9 +-------- 3 files changed, 3 insertions(+), 24 deletions(-) diff --git a/src/pages/briefs/[category].astro b/src/pages/briefs/[category].astro index 544e52a..65d5942 100644 --- a/src/pages/briefs/[category].astro +++ b/src/pages/briefs/[category].astro @@ -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..2c32ac9 100644 --- a/src/pages/briefs/index.astro +++ b/src/pages/briefs/index.astro @@ -13,13 +13,6 @@ 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 }; - }) -); - type BriefList = CollectionEntry<"briefs">[]; type Brief = CollectionEntry<"briefs">; @@ -31,7 +24,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..d613794 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -19,18 +19,11 @@ const projects = (await getCollection("projects")) .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()) .slice(0,SITE.NUM_PROJECTS_ON_HOMEPAGE); -const allwork = (await getCollection("briefs")) +const briefs = (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 ogData = getHomeOGData( Astro.url.toString(), Astro.site?.toString() || "" From ffea2c63db21e6fc7578f2852508e3d483b74d7f Mon Sep 17 00:00:00 2001 From: plx Date: Sat, 20 Jun 2026 04:45:26 -0500 Subject: [PATCH 2/8] perf: cache brief category metadata lookups during builds (#32) `getCategory` performed synchronous `existsSync` + `readFileSync` (and a YAML parse) on every call. Brief card generation calls it once per card, so a category's `category.yaml` was re-read and re-parsed many times per build. Memoize resolved category metadata in a module-level `Map`, keyed by slug + path, so each category's YAML is read and parsed at most once per build. Missing/invalid-YAML fallback to default metadata is preserved (the resolved default is what gets cached). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/category.ts | Bin 2397 -> 3173 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/lib/category.ts b/src/lib/category.ts index bde4f330ce2fd67517ad7e913768b3990f869f9e..5aabc03e740492df407331104f0f36555878e58a 100644 GIT binary patch delta 892 zcmY*X&5qMB5JpI?P>)C;kU$!t6|~UJa;6jnD-H`(;t02O<4J1Ov4idGDwHZb1G(`O zNW1`do`ECp!Hm;HP~}kDGvD9L@9TeVeZN0y&dhOvg>^NgBHbX^3>qAih5Qr8SNwI)R<(M7=79HG59(efhV)YgjhewaRyl|gqQXRxC)kke2(LwyXuCHo1Zwm(N(+QI?;u(?aH z7wDXlJVN?CKcKYb_|8qfNRnizn8%X#+xYQ&e%i<56OfpRN{4t!dxkM-LVfUQe$&u!&punY|keQ~Sp_pHmT2z#ol3J{(Pz@AQ zP$)_*DJ{xVNJ&jgEX^r#PAo}H&o8R92Fld}F%Ur&RC7(1 Date: Sat, 20 Jun 2026 04:48:47 -0500 Subject: [PATCH 3/8] fix: correct reading time calculation; add unit-test harness (#33) `readingTime` added an unconditional `+1` minute before rounding, so every post's estimate was inflated (e.g. a 200-word post read as "2 min read"). Switch to the standard formula `max(1, ceil(words / 200))` and normalize empty/whitespace-only content to "1 min read". This is also the first code in the repo to get unit coverage, so add a minimal Vitest harness: - add `vitest` (devDependency) and a `test:unit` script - `vitest.config.ts` scopes Vitest to `src/**/*.test.ts` so it doesn't collide with the Playwright `tests/*.spec.ts` suites - `src/lib/utils.test.ts` covers the reading-time edge cases called out in the issue (0/1/199/200/201/399/400 words, empty content, HTML stripping, round-up) - wire `npm run test:unit` into the shared CI workflow and the local `test:ci` aggregates Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 5 +- package-lock.json | 392 ++++++++++++++++++++++++++++++++++-- package.json | 8 +- src/lib/utils.test.ts | 39 ++++ src/lib/utils.ts | 12 +- vitest.config.ts | 10 + 6 files changed, 444 insertions(+), 22 deletions(-) create mode 100644 src/lib/utils.test.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e9a66f8..aabdcd6 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 025a540..c8a1757 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,8 @@ "cspell": "^9.2.0", "eslint-plugin-jsx-a11y": "^6.10.2", "markdownlint-cli2": "^0.21.0", - "prettier": "^3.6.2" + "prettier": "^3.6.2", + "vitest": "^4.1.9" } }, "node_modules/@alloc/quick-lru": { @@ -3019,6 +3020,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/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -3093,6 +3101,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.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -3102,6 +3121,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/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3490,6 +3516,133 @@ "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/runner/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "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/snapshot/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "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/kit": { "version": "2.4.23", "resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.23.tgz", @@ -3866,6 +4019,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -4537,6 +4700,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.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", @@ -6387,6 +6560,16 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "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.3", "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.41.3.tgz", @@ -8464,12 +8647,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/magicast": { @@ -10088,6 +10271,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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.4.1", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.4.1.tgz", @@ -10454,9 +10651,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -11697,6 +11894,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -11802,6 +12006,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/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -12337,6 +12555,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": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -12344,13 +12569,13 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -12359,6 +12584,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", @@ -13140,6 +13375,120 @@ } } }, + "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/vitest/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/volar-service-css": { "version": "0.0.62", "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.62.tgz", @@ -13537,6 +13886,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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 7b936d4..c7c09d0 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,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", @@ -65,6 +66,7 @@ "cspell": "^9.2.0", "eslint-plugin-jsx-a11y": "^6.10.2", "markdownlint-cli2": "^0.21.0", - "prettier": "^3.6.2" + "prettier": "^3.6.2", + "vitest": "^4.1.9" } } diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts new file mode 100644 index 0000000..8798abf --- /dev/null +++ b/src/lib/utils.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { 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"); + }); +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 03b4e2f..de8d0bb 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -13,11 +13,13 @@ 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`; } export function dateRange(startDate: Date, endDate?: Date | string): string { 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"], + }, +}); From fc8b69c91606ca98e92282ba54f696e95c77006f Mon Sep 17 00:00:00 2001 From: plx Date: Sat, 20 Jun 2026 04:49:22 -0500 Subject: [PATCH 4/8] refactor: narrow and deduplicate project card helper typing (#34) `getProjectCardProps` accepted `CollectionEntry<"blog"> | CollectionEntry<"projects">` despite being the project helper, and duplicated `getBlogCardProps` verbatim. Narrow its input to `CollectionEntry<"projects">` and route both the blog and project helpers through a shared internal `getStandardCardProps` mapping. `getBriefCardProps` stays specialized because it adds the category-prefix behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/contentCardHelpers.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) 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); } /** From 812d8df0521885517618b75d6ec42ca30d9ce1bb Mon Sep 17 00:00:00 2001 From: plx Date: Sat, 20 Jun 2026 04:49:51 -0500 Subject: [PATCH 5/8] chore: remove stale CallToAction component (#35) `CallToAction.astro` was unused dead code left over from the accessible-astro-starter template. It imported `../assets/scss/base/mixins`, a path that does not exist in this project (there is no `src/assets` directory), so it would have failed to build the moment anything imported it. Its default copy/links also pointed at the upstream starter theme. Remove it rather than retrofit a component nothing references. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/CallToAction.astro | 86 ------------------------------- 1 file changed, 86 deletions(-) delete mode 100644 src/components/CallToAction.astro 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 ---- - -
    -
    -

    {title}

    - - {linkText} - - -
    -
    - - From 65f656873b14a8b231fdb98aa45bd3ad2ba79adf Mon Sep 17 00:00:00 2001 From: plx Date: Sat, 20 Jun 2026 04:50:50 -0500 Subject: [PATCH 6/8] fix: correct dateRange formatting and open-ended end dates (#36) `dateRange` concatenated month and year with no separator ("Jan2020") and, when `endDate` was omitted, produced "undefinedundefined" because the end month/year were never assigned. Rework it to format each endpoint as `MMM YYYY` (deterministic en-US locale, matching `formatDate`) and join with " - ". The end label is the provided string verbatim when a string is passed, a formatted `MMM YYYY` when a Date is passed, and defaults to "Present" when omitted. Add tests covering all three branches and asserting no `undefined` fragments appear. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/utils.test.ts | 27 ++++++++++++++++++++++++++- src/lib/utils.ts | 38 ++++++++++++++++++++++++-------------- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 8798abf..2422e47 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { readingTime } from "./utils"; +import { dateRange, readingTime } from "./utils"; /** Build a string containing exactly `count` whitespace-separated words. */ function words(count: number): string { @@ -37,3 +37,28 @@ describe("readingTime", () => { 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 de8d0bb..ba9bfa6 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -22,21 +22,31 @@ export function readingTime(html: string): string { 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 From 2bd3c7f7e4f25f3337245f6e909d0f7e5f2240c7 Mon Sep 17 00:00:00 2001 From: plx Date: Sat, 20 Jun 2026 04:51:44 -0500 Subject: [PATCH 7/8] feat: validate URL-like frontmatter fields in content schema (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URL-valued frontmatter fields were typed as plain `z.string()`, so a malformed link in frontmatter sailed through validation and could emit a broken link into a generated page. Tighten the project `demoURL`/`repoURL` fields and the `ogImage` fields (blog/briefs/projects) to `z.string().url()`. Both are semantically absolute URLs — `demoURL`/`repoURL` are rendered as external links and OG images must be absolute per the Open Graph protocol — so the absolute-only policy `.url()` enforces is the intended one; relative paths are rejected. `.optional()` is preserved. All existing project content already uses absolute https URLs, so `astro check` and link validation still pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/content/config.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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() From 4f1ba3730d3ce0bf357e1f91fad63e0461d49ffa Mon Sep 17 00:00:00 2001 From: plx Date: Sat, 20 Jun 2026 04:55:36 -0500 Subject: [PATCH 8/8] refactor: centralize duplicated collection query logic (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "fetch a collection, drop drafts, sort newest-first (optionally sliced)" pipeline was copy-pasted across every list page, every detail route's `getStaticPaths`, and the RSS endpoint. Any change to the draft/sort/slice policy had to be made in ~9 places. Add `src/lib/collections.ts` exposing: - `published(entries)` — drop drafts - `byDateDesc(entries)` — date-descending sort (non-mutating) - `getPublishedCollection(name, limit?)` — the common fetch+filter+sort, with an optional limit for homepage previews Route every consumer through these helpers. The home/blog/briefs/projects index pages and the blog/projects/briefs detail routes use `getPublishedCollection`; the per-category brief page and the RSS endpoint (which filter/merge before sorting) compose `published` + `byDateDesc`. Ordering, draft exclusion, and slice counts are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/collections.ts | 30 ++++++++++++++++++++++++++++++ src/pages/blog/[...slug].astro | 7 +++---- src/pages/blog/index.astro | 7 +++---- src/pages/briefs/[...slug].astro | 7 +++---- src/pages/briefs/[category].astro | 14 +++++++------- src/pages/briefs/index.astro | 7 +++---- src/pages/index.astro | 20 ++++---------------- src/pages/projects/[...slug].astro | 7 +++---- src/pages/projects/index.astro | 6 ++---- src/pages/rss.xml.ts | 11 ++++------- 10 files changed, 62 insertions(+), 54 deletions(-) create mode 100644 src/lib/collections.ts 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/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 65d5942..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) + ) } })); } diff --git a/src/pages/briefs/index.astro b/src/pages/briefs/index.astro index 2c32ac9..3cdb54a 100644 --- a/src/pages/briefs/index.astro +++ b/src/pages/briefs/index.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 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 collection = await getPublishedCollection("briefs"); type BriefList = CollectionEntry<"briefs">[]; type Brief = CollectionEntry<"briefs">; diff --git a/src/pages/index.astro b/src/pages/index.astro index d613794..199d9c3 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,28 +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 briefs = (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 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,