From 508b2030f77160b68087590cd9c5656eb0d2a2d5 Mon Sep 17 00:00:00 2001 From: Ken Date: Tue, 26 May 2026 23:33:35 +0000 Subject: [PATCH] fix: normalize BASE_URL trailing slash in post links and RSS feed The Astro BASE_URL (/crashtestdev) lacked a trailing slash, causing post URLs to render as /crashtestdevposts/... instead of /crashtestdev/posts/... Breaking article card navigation and RSS feed links. Applied the same .replace(/\/?$/, '/') normalization pattern already used in BaseLayout.astro to: - ArticleCard.astro (card link hrefs) - NavBar.astro (isActive path matching) - rss.xml.ts (item link generation) Also adds final-verification.test.mjs with 31 tests covering: - Clean build output structure - Index page cards, shadows, hover states, URL format - Post page NavBar active state, PostHeader centering - Three typography voices (prose/structure/evidence) - Code block breakout width, warm background, terracotta border - Design token color compliance - Responsive behavior (nav stacking, title shrinking, gutter respect) - RSS feed validity and URL correctness --- src/components/ArticleCard.astro | 2 +- src/components/NavBar.astro | 2 +- src/pages/rss.xml.ts | 2 +- tests/final-verification.test.mjs | 273 ++++++++++++++++++++++++++++++ 4 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 tests/final-verification.test.mjs diff --git a/src/components/ArticleCard.astro b/src/components/ArticleCard.astro index cd82b9e..8086d01 100644 --- a/src/components/ArticleCard.astro +++ b/src/components/ArticleCard.astro @@ -9,7 +9,7 @@ interface Props { } const { title, date, type, summary, slug, readingTime } = Astro.props; -const base = import.meta.env.BASE_URL; +const base = import.meta.env.BASE_URL.replace(/\/?$/, '/'); const formattedDate = date.toLocaleDateString('en-US', { year: 'numeric', diff --git a/src/components/NavBar.astro b/src/components/NavBar.astro index 37ba9cd..69148b8 100644 --- a/src/components/NavBar.astro +++ b/src/components/NavBar.astro @@ -1,6 +1,6 @@ --- const currentPath = Astro.url.pathname; -const base = import.meta.env.BASE_URL; +const base = import.meta.env.BASE_URL.replace(/\/?$/, '/'); const navLinks = [ { label: 'Posts', href: base }, diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts index cec774b..76d3461 100644 --- a/src/pages/rss.xml.ts +++ b/src/pages/rss.xml.ts @@ -4,7 +4,7 @@ import type { APIContext } from 'astro'; export async function GET(context: APIContext) { const posts = await getCollection('posts'); - const base = import.meta.env.BASE_URL; + const base = import.meta.env.BASE_URL.replace(/\/?$/, '/'); const sortedPosts = posts.sort( (a, b) => b.data.date.getTime() - a.data.date.getTime() diff --git a/tests/final-verification.test.mjs b/tests/final-verification.test.mjs new file mode 100644 index 0000000..5d8321b --- /dev/null +++ b/tests/final-verification.test.mjs @@ -0,0 +1,273 @@ +/** + * Final verification test suite for the Astro rebuild. + * Validates the built output against the design spec checklist. + */ +import { readFileSync, existsSync, readdirSync } from 'fs'; +import { execSync } from 'child_process'; +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; + +const DIST = 'dist'; + +// Helper: read file as string +function readDist(path) { + return readFileSync(`${DIST}/${path}`, 'utf-8'); +} + +// ============================================================ +// 1. Clean build produces expected output files +// ============================================================ +describe('Clean build output', () => { + test('dist/index.html exists', () => { + assert.ok(existsSync(`${DIST}/index.html`)); + }); + + test('dist/rss.xml exists', () => { + assert.ok(existsSync(`${DIST}/rss.xml`)); + }); + + test('post pages exist', () => { + const postsDir = `${DIST}/posts`; + assert.ok(existsSync(postsDir)); + const slugs = readdirSync(postsDir); + assert.ok(slugs.length >= 2, `Expected at least 2 post slugs, got ${slugs.length}`); + }); +}); + +// ============================================================ +// 2. Index page structure +// ============================================================ +describe('Index page', () => { + const html = readDist('index.html'); + + test('contains two article cards', () => { + const cardMatches = html.match(/]*class="card"/g); + assert.ok(cardMatches, 'No article cards found'); + assert.equal(cardMatches.length, 2, `Expected 2 article cards, got ${cardMatches.length}`); + }); + + test('card surface uses warm off-white (--surface-card)', () => { + assert.ok(html.includes('var(--surface-card)'), 'Card should use --surface-card token'); + }); + + test('card has subtle shadow (--shadow-sm)', () => { + assert.ok(html.includes('var(--shadow-sm)'), 'Card should use --shadow-sm token'); + }); + + test('card hover shows deeper shadow (--shadow-md)', () => { + assert.ok(html.includes('var(--shadow-md)'), 'Card hover should use --shadow-md token'); + }); + + test('card hover shows terracotta title (--accent-primary)', () => { + assert.ok( + html.includes(':hover .title') || html.includes(':hover .title{'), + 'Card hover should style the title' + ); + assert.ok(html.includes('var(--accent-primary)'), 'Title hover should use terracotta accent'); + }); + + test('card links have correct URL format with slash separator', () => { + // The base is /crashtestdev and posts must be at /crashtestdev/posts/... + const linkRegex = /href="([^"]*posts[^"]*)"/g; + let match; + let found = false; + while ((match = linkRegex.exec(html)) !== null) { + found = true; + const url = match[1]; + assert.ok( + url.includes('/posts/'), + `URL "${url}" should contain "/posts/" with proper slash separator` + ); + assert.ok( + !url.includes('devposts'), + `URL "${url}" should not concatenate base and posts without slash` + ); + } + assert.ok(found, 'Should find article card links'); + }); +}); + +// ============================================================ +// 3. Post page structure +// ============================================================ +describe('Post page', () => { + // Find first post + const postsDir = `${DIST}/posts`; + const firstSlug = readdirSync(postsDir)[0]; + const html = readDist(`posts/${firstSlug}/index.html`); + + test('has NavBar with site navigation', () => { + assert.ok(html.includes('aria-label="Site navigation"'), 'Should have site navigation'); + }); + + test('NavBar has Posts link with active state (terracotta underline)', () => { + assert.ok(html.includes('nav-link'), 'Should have nav links'); + // On post pages, the nav link should have the 'active' class + assert.ok( + html.includes('class="nav-link active"') || html.includes('active'), + 'Should have active state on Posts link' + ); + // Verify the source NavBar component defines the terracotta underline for active links + const navbar = readFileSync('src/components/NavBar.astro', 'utf-8'); + assert.ok( + navbar.includes('text-decoration-color: var(--accent-primary)'), + 'Active link should have terracotta underline defined in source' + ); + }); + + test('has PostHeader with badge', () => { + assert.ok(html.includes('post-header'), 'Should have post-header'); + assert.ok(html.includes('class="badge"'), 'Should have badge'); + }); + + test('has PostHeader with title', () => { + assert.ok(html.includes('class="title"'), 'Should have title'); + }); + + test('has PostHeader with date and reading time', () => { + assert.ok(html.includes(' { + // Astro scoped styles are compiled to external CSS files, not inline in HTML. + // Check the source component instead. + const postHeader = readFileSync('src/components/PostHeader.astro', 'utf-8'); + assert.ok( + postHeader.includes('text-align: center'), + 'PostHeader should be centered in source component' + ); + }); +}); + +// ============================================================ +// 4. Typography: Three distinct voices +// ============================================================ +describe('Typography voices', () => { + const tokens = readFileSync('src/styles/tokens.css', 'utf-8'); + + test('Prose voice: Source Serif 4 via --font-prose', () => { + assert.ok(tokens.includes('--font-prose')); + assert.ok(tokens.includes('Source Serif 4')); + }); + + test('Structure voice: Inter via --font-structure', () => { + assert.ok(tokens.includes('--font-structure')); + assert.ok(tokens.includes('Inter')); + }); + + test('Evidence voice: JetBrains Mono via --font-evidence', () => { + assert.ok(tokens.includes('--font-evidence')); + assert.ok(tokens.includes('JetBrains Mono')); + }); +}); + +// ============================================================ +// 5. Code blocks +// ============================================================ +describe('Code blocks', () => { + const prose = readFileSync('src/styles/prose.css', 'utf-8'); + + test('break wider than prose (--width-code > --width-prose)', () => { + assert.ok(prose.includes('--width-code'), 'Code blocks should use --width-code'); + const tokens = readFileSync('src/styles/tokens.css', 'utf-8'); + const proseWidth = tokens.match(/--width-prose:\s*([^;]+)/)?.[1]?.trim(); + const codeWidth = tokens.match(/--width-code:\s*([^;]+)/)?.[1]?.trim(); + assert.ok(parseFloat(codeWidth) > parseFloat(proseWidth), + `Code width (${codeWidth}) must be larger than prose width (${proseWidth})`); + }); + + test('warm background (#EEEAE2 via --surface-code)', () => { + const tokens = readFileSync('src/styles/tokens.css', 'utf-8'); + assert.ok(tokens.includes('--surface-code: #EEEAE2')); + }); + + test('3px terracotta left border', () => { + assert.ok(prose.includes('border-left: 3px solid var(--accent-primary)')); + }); +}); + +// ============================================================ +// 6. Design token colors +// ============================================================ +describe('Design token colors', () => { + const tokens = readFileSync('src/styles/tokens.css', 'utf-8'); + + test('page background #F5F0E8', () => { + assert.ok(tokens.includes('#F5F0E8'), 'Should have #F5F0E8 as surface-base'); + }); + + test('text color #2B2421', () => { + assert.ok(tokens.includes('#2B2421'), 'Should have #2B2421 as text-primary'); + }); + + test('link color #A0522D', () => { + assert.ok(tokens.includes('#A0522D'), 'Should have #A0522D as accent-primary'); + }); +}); + +// ============================================================ +// 7. Responsive design +// ============================================================ +describe('Responsive behavior', () => { + test('NavBar stacks on mobile', () => { + const navbar = readFileSync('src/components/NavBar.astro', 'utf-8'); + assert.ok(navbar.includes('flex-direction: column') || navbar.includes('flex-direction:column'), + 'Nav should stack on mobile'); + assert.ok(navbar.includes('@media'), 'Should have responsive media query'); + }); + + test('PostHeader title shrinks on mobile', () => { + const header = readFileSync('src/components/PostHeader.astro', 'utf-8'); + assert.ok(header.includes('@media'), 'PostHeader should have responsive media queries'); + // Check that smaller type sizes are used at breakpoints + assert.ok(header.includes('--type-2xl') || header.includes('--type-xl'), + 'Title should use smaller type scale at breakpoints'); + }); + + test('code blocks respect gutters on mobile', () => { + const prose = readFileSync('src/styles/prose.css', 'utf-8'); + assert.ok(prose.includes('100vw') && prose.includes('--gutter'), + 'Code blocks should constrain to viewport minus gutters'); + }); +}); + +// ============================================================ +// 8. RSS feed +// ============================================================ +describe('RSS feed', () => { + const rss = readDist('rss.xml'); + + test('is valid XML (starts with xml declaration)', () => { + assert.ok(rss.startsWith(' { + assert.ok(rss.includes('Crash Test Dev')); + }); + + test('contains at least 2 items', () => { + const items = rss.match(//g); + assert.ok(items && items.length >= 2, `Expected at least 2 RSS items, got ${items?.length}`); + }); + + test('item links have correct URL format', () => { + // RSS links should be full URLs with proper path + const linkRegex = /(https?:\/\/[^<]+posts[^<]*)<\/link>/g; + let match; + let found = false; + while ((match = linkRegex.exec(rss)) !== null) { + found = true; + const url = match[1]; + assert.ok( + url.includes('/posts/'), + `RSS URL "${url}" should contain "/posts/" with proper slash separator` + ); + assert.ok( + !url.includes('devposts'), + `RSS URL "${url}" should not concatenate base and posts without slash` + ); + } + assert.ok(found, 'Should find post links in RSS'); + }); +}); \ No newline at end of file