Add Markdown blog and FAQ content collections

This commit is contained in:
Kevin
2026-09-02 10:25:49 -04:00
parent e45bbe4111
commit 78cfe0cc71
20 changed files with 176 additions and 27 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ name: Deploy blog static Worker preview
on: on:
push: push:
branches: [astro-platform-pilot] branches: [astro-platform-pilot, markdown-content-pilot]
paths: paths:
- .gitea/workflows/deploy-blog.yml - .gitea/workflows/deploy-blog.yml
- astro.config.mjs - astro.config.mjs
+1 -1
View File
@@ -2,7 +2,7 @@ name: Deploy primary static Worker preview
on: on:
push: push:
branches: [astro-platform-pilot] branches: [astro-platform-pilot, markdown-content-pilot]
paths: paths:
- .gitea/workflows/deploy.yml - .gitea/workflows/deploy.yml
- astro.config.mjs - astro.config.mjs
+6 -5
View File
@@ -11,9 +11,9 @@ Cloudflare Pages site remains the rollback target while this branch is tested.
- `src/` contains shared Astro presentation code. - `src/` contains shared Astro presentation code.
- `schemas/` and `fixtures/` provide the Phase 1 JSON contract proof. - `schemas/` and `fixtures/` provide the Phase 1 JSON contract proof.
The blog is static JSON in this phase. Markdown content, CMS workflows, public Blog posts and primary-site FAQs are now static Markdown collections with
sign-up changes, n8n changes, and onboarding-worker changes are deliberately validated frontmatter and draft filtering. CMS workflows, public sign-up
out of scope. changes, n8n changes, and onboarding-worker changes remain out of scope.
## Local checks ## Local checks
@@ -33,8 +33,9 @@ routes, account IDs, or secrets.
## Preview deployment ## Preview deployment
The Gitea workflow deploys only a non-production Worker from the pilot branch: The Gitea workflows deploy scoped non-production Workers from the pilot and
`3031023-xyz-www-preview` by default, or the manually selected `blog` site. Markdown-test branches: `3031023-xyz-www-preview` for primary-site changes and
`3031023-xyz-blog-preview` for blog changes.
It uses the existing `CF_API_TOKEN` and `CF_ACCOUNT_ID`. The token retains the It uses the existing `CF_API_TOKEN` and `CF_ACCOUNT_ID`. The token retains the
Pages permissions needed for the `main` rollback target and now also permits Pages permissions needed for the `main` rollback target and now also permits
the non-production Worker preview deployment. the non-production Worker preview deployment.
+15 -4
View File
@@ -6,10 +6,21 @@ whether a physical-address map is displayed. Shared Astro components render
that data without a per-site code fork. that data without a per-site code fork.
Current reusable primitives include the base SEO layout, header/footer, Current reusable primitives include the base SEO layout, header/footer,
full-page hero, section heading, CTA band, review cards, and optional location full-page hero, section heading, CTA band, review cards, Markdown-backed FAQ
map. The optional `faq` array provides accessible, static question/answer accordions, and an optional location map. A real logo can be provided as
accordions and stays empty until approved content exists. A real logo can be provided as `/images/<safe-name>.svg` or image asset; `/images/<safe-name>.svg` or image asset; until then the configured short mark
until then the configured short mark and shared favicon are used. and shared favicon are used.
Phase 6 adds two validated Markdown collections:
- `sites/blog/content/posts/*.md` produces the journal listing and individual
`/posts/<slug>/` pages on `blog.3031023.xyz`.
- `sites/www/content/faq/*.md` produces the FAQ section on the primary-site
homepage.
Both collections require frontmatter and support `draft: true`. Draft entries
are excluded from the rendered pages and sitemap. This is a Git-backed pilot;
it does not add a CMS or client-editing access.
Images must live under `public/images/`, be referenced with descriptive alt Images must live under `public/images/`, be referenced with descriptive alt
text where rendered, and remain at or below the 500 KiB pilot limit. The build text where rendered, and remain at or below the 500 KiB pilot limit. The build
+2 -6
View File
@@ -17,10 +17,8 @@
"features": { "type": "array", "maxItems": 12, "items": { "$ref": "#/$defs/card" } }, "features": { "type": "array", "maxItems": 12, "items": { "$ref": "#/$defs/card" } },
"services": { "type": "array", "maxItems": 24, "items": { "$ref": "#/$defs/service" } }, "services": { "type": "array", "maxItems": 24, "items": { "$ref": "#/$defs/service" } },
"reviews": { "type": "array", "maxItems": 12, "items": { "$ref": "#/$defs/review" } }, "reviews": { "type": "array", "maxItems": 12, "items": { "$ref": "#/$defs/review" } },
"faq": { "type": "array", "maxItems": 20, "items": { "$ref": "#/$defs/faq" } },
"story": { "$ref": "#/$defs/story" }, "story": { "$ref": "#/$defs/story" },
"team": { "type": "array", "maxItems": 20, "items": { "$ref": "#/$defs/team" } }, "team": { "type": "array", "maxItems": 20, "items": { "$ref": "#/$defs/team" } }
"posts": { "type": "array", "maxItems": 24, "items": { "$ref": "#/$defs/post" } }
}, },
"$defs": { "$defs": {
"text": { "type": "string", "minLength": 1, "maxLength": 1000 }, "text": { "type": "string", "minLength": 1, "maxLength": 1000 },
@@ -34,9 +32,7 @@
"card": { "type": "object", "additionalProperties": false, "required": ["title", "description"], "properties": { "icon": { "type": "string", "maxLength": 8 }, "title": { "$ref": "#/$defs/text" }, "description": { "$ref": "#/$defs/text" } } }, "card": { "type": "object", "additionalProperties": false, "required": ["title", "description"], "properties": { "icon": { "type": "string", "maxLength": 8 }, "title": { "$ref": "#/$defs/text" }, "description": { "$ref": "#/$defs/text" } } },
"service": { "type": "object", "additionalProperties": false, "required": ["title", "description", "price", "items"], "properties": { "title": { "$ref": "#/$defs/text" }, "description": { "$ref": "#/$defs/text" }, "price": { "type": "string", "maxLength": 48 }, "items": { "type": "array", "minItems": 1, "maxItems": 12, "items": { "$ref": "#/$defs/text" } } } }, "service": { "type": "object", "additionalProperties": false, "required": ["title", "description", "price", "items"], "properties": { "title": { "$ref": "#/$defs/text" }, "description": { "$ref": "#/$defs/text" }, "price": { "type": "string", "maxLength": 48 }, "items": { "type": "array", "minItems": 1, "maxItems": 12, "items": { "$ref": "#/$defs/text" } } } },
"review": { "type": "object", "additionalProperties": false, "required": ["quote", "author", "rating"], "properties": { "quote": { "$ref": "#/$defs/text" }, "author": { "type": "string", "minLength": 1, "maxLength": 120 }, "rating": { "type": "integer", "minimum": 1, "maximum": 5 } } }, "review": { "type": "object", "additionalProperties": false, "required": ["quote", "author", "rating"], "properties": { "quote": { "$ref": "#/$defs/text" }, "author": { "type": "string", "minLength": 1, "maxLength": 120 }, "rating": { "type": "integer", "minimum": 1, "maximum": 5 } } },
"faq": { "type": "object", "additionalProperties": false, "required": ["question", "answer"], "properties": { "question": { "type": "string", "minLength": 1, "maxLength": 180 }, "answer": { "$ref": "#/$defs/text" } } },
"story": { "type": "object", "additionalProperties": false, "required": ["heading", "copy", "image", "imageAlt"], "properties": { "heading": { "$ref": "#/$defs/text" }, "copy": { "$ref": "#/$defs/text" }, "image": { "$ref": "#/$defs/imagePath" }, "imageAlt": { "$ref": "#/$defs/text" } } }, "story": { "type": "object", "additionalProperties": false, "required": ["heading", "copy", "image", "imageAlt"], "properties": { "heading": { "$ref": "#/$defs/text" }, "copy": { "$ref": "#/$defs/text" }, "image": { "$ref": "#/$defs/imagePath" }, "imageAlt": { "$ref": "#/$defs/text" } } },
"team": { "type": "object", "additionalProperties": false, "required": ["name", "role", "bio", "image"], "properties": { "name": { "$ref": "#/$defs/text" }, "role": { "$ref": "#/$defs/text" }, "bio": { "$ref": "#/$defs/text" }, "image": { "$ref": "#/$defs/imagePath" } } }, "team": { "type": "object", "additionalProperties": false, "required": ["name", "role", "bio", "image"], "properties": { "name": { "$ref": "#/$defs/text" }, "role": { "$ref": "#/$defs/text" }, "bio": { "$ref": "#/$defs/text" }, "image": { "$ref": "#/$defs/imagePath" } } }
"post": { "type": "object", "additionalProperties": false, "required": ["title", "excerpt", "date", "tag"], "properties": { "title": { "$ref": "#/$defs/text" }, "excerpt": { "$ref": "#/$defs/text" }, "date": { "$ref": "#/$defs/text" }, "tag": { "$ref": "#/$defs/text" } } }
} }
} }
+17
View File
@@ -14,4 +14,21 @@ for (const { file, html } of pages) {
if (!/<title>[^<]+<\/title>/.test(html)) throw new Error(`${file} is missing a title.`); if (!/<title>[^<]+<\/title>/.test(html)) throw new Error(`${file} is missing a title.`);
for (const match of html.matchAll(/(?:src|href)="(\/images\/[^"?#]+)"/g)) await access(resolve(root, `.${match[1]}`)); for (const match of html.matchAll(/(?:src|href)="(\/images\/[^"?#]+)"/g)) await access(resolve(root, `.${match[1]}`));
} }
const home = await readFile(resolve(root, 'index.html'), 'utf8');
if (key === 'www') {
if (!home.includes('What services do you offer?') || !home.includes('How do I book a visit?')) throw new Error('Primary FAQ Markdown entries are missing from the homepage.');
if (home.includes('What information should I bring to a meet and greet?')) throw new Error('Draft FAQ entry appeared in the homepage.');
}
if (key === 'blog') {
const publishedPosts = ['first-walk-after-work', 'gentler-grooming-day', 'overnight-care-checklist'];
await Promise.all(publishedPosts.map((slug) => access(resolve(root, `posts/${slug}/index.html`))));
if (home.includes('Draft only example for editorial review')) throw new Error('Draft blog post appeared in the journal.');
try {
await access(resolve(root, 'posts/draft-only-example/index.html'));
throw new Error('Draft blog post generated a public page.');
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
console.log(`Verified ${key}: ${required.join(', ')}`); console.log(`Verified ${key}: ${required.join(', ')}`);
@@ -0,0 +1,9 @@
---
title: Draft only example for editorial review
description: A deliberately unpublished entry used to verify that drafts remain out of the generated journal and sitemap.
pubDate: 2026-09-03
tags: [Editorial]
draft: true
---
This draft should never appear in the generated site until its frontmatter is changed to `draft: false`.
@@ -0,0 +1,17 @@
---
title: A better first walk after a busy day
description: Three low-pressure ways to help your dog transition from indoor energy to an enjoyable neighborhood walk.
pubDate: 2026-09-02
tags: [Walking, Routine]
draft: false
---
The first few minutes outside can set the tone for the whole walk. Start with a slower block, let your dog sniff, and save the brisk pace for after they have settled in.
## Try this simple reset
1. Fill the water bowl before leaving.
2. Give your dog a minute to look and sniff near home.
3. Keep the first turn predictable, then add a little variety later.
Small routines make the end of a busy day feel calmer for everyone.
@@ -0,0 +1,11 @@
---
title: How to make grooming day feel gentler
description: A few familiar at-home routines can make a professional grooming appointment calmer for dogs and their people.
pubDate: 2026-08-26
tags: [Grooming, Comfort]
draft: false
---
Grooming goes more smoothly when the experience is built from small, familiar steps. A short brush at home and a quiet arrival can help your dog know what to expect.
Bring any care notes that matter to your dog, such as a sensitive spot or a favorite way to be greeted. Clear details help the grooming team work at your dog’s pace.
@@ -0,0 +1,18 @@
---
title: The overnight checklist we share with every family
description: What to pack and what to share with a sitter so your dog can keep a familiar rhythm while you are away.
pubDate: 2026-08-19
tags: [Overnight care, Planning]
draft: false
---
The best overnight plan is usually the one that feels most like home. Pack enough food, a familiar blanket, and the small routine details that make your dog feel settled.
## Share the essentials
- Feeding and medication instructions
- Walk and sleep routines
- Your veterinarian and emergency contact
- A few things your dog enjoys and avoids
This pilot post is sample content for testing the Markdown workflow.
+1 -3
View File
@@ -5,7 +5,5 @@
"branding": { "wordmark": "Happy Tails Journal", "mark": "HJ", "logo": null }, "branding": { "wordmark": "Happy Tails Journal", "mark": "HJ", "logo": null },
"seo": { "socialImage": "/images/puppy-playing.jpg" }, "seo": { "socialImage": "/images/puppy-playing.jpg" },
"nav": [{ "label": "Journal", "href": "/" }, { "label": "Happy Tails", "href": "https://3031023.xyz" }], "nav": [{ "label": "Journal", "href": "/" }, { "label": "Happy Tails", "href": "https://3031023.xyz" }],
"hero": { "eyebrow": "From the Happy Tails pack", "headline": "Better days with your best dog.", "copy": "A small, static sample blog for tips, routines, and dog-care inspiration.", "image": "/images/puppy-playing.jpg", "imageAlt": "A playful puppy outdoors", "cta": "Read the journal", "ctaHref": "/" }, "hero": { "eyebrow": "From the Happy Tails pack", "headline": "Better days with your best dog.", "copy": "A small, static Markdown-powered journal for tips, routines, and dog-care inspiration.", "image": "/images/puppy-playing.jpg", "imageAlt": "A playful puppy outdoors", "cta": "Read the journal", "ctaHref": "/" }
"posts": [{ "title": "A better first walk after a busy day", "excerpt": "Three easy ways to help your dog shift from indoor energy to an enjoyable neighborhood walk.", "date": "September 2, 2026", "tag": "Walking" }, { "title": "How to make grooming day feel gentler", "excerpt": "Small routines at home can make a professional grooming appointment calmer for everyone.", "date": "August 26, 2026", "tag": "Grooming" }, { "title": "The overnight checklist we share with every family", "excerpt": "What to pack—and what to tell your sitter—so your dog keeps their familiar rhythm.", "date": "August 19, 2026", "tag": "Overnight care" }],
"faq": []
} }
@@ -0,0 +1,8 @@
---
question: What information should I bring to a meet and greet?
category: Booking
order: 30
draft: true
---
This is a deliberately unpublished FAQ answer. It verifies that drafts do not appear on the public home page.
+8
View File
@@ -0,0 +1,8 @@
---
question: How do I book a visit?
category: Booking
order: 20
draft: false
---
For this pilot, the contact form is intentionally disabled until the approved server-mapped contact workflow is connected. The page clearly explains that it cannot send a request yet.
@@ -0,0 +1,8 @@
---
question: What services do you offer?
category: Services
order: 10
draft: false
---
Happy Tails offers sample **dog walking, grooming, and overnight care** options in this pilot site. A live client site would replace this answer with approved business details.
-1
View File
@@ -11,7 +11,6 @@
"features": [{ "icon": "◎", "title": "Personalized care", "description": "Every visit follows your dog's pace, routine, and personality." }, { "icon": "↗", "title": "Updates you can trust", "description": "Photos and thoughtful notes keep you close to every adventure." }, { "icon": "✦", "title": "Experienced hands", "description": "A small, caring team with safety and comfort at the center." }], "features": [{ "icon": "◎", "title": "Personalized care", "description": "Every visit follows your dog's pace, routine, and personality." }, { "icon": "↗", "title": "Updates you can trust", "description": "Photos and thoughtful notes keep you close to every adventure." }, { "icon": "✦", "title": "Experienced hands", "description": "A small, caring team with safety and comfort at the center." }],
"services": [{ "title": "Dog walking", "description": "One-on-one neighborhood walks with photo updates and water breaks.", "price": "$25 / walk", "items": ["GPS-tracked routes", "Flexible scheduling", "Post-walk notes"] }, { "title": "Grooming", "description": "A calmer, kinder grooming day for dogs of every size.", "price": "From $40", "items": ["Bathing & blow-dry", "Nail and ear care", "Full groom available"] }, { "title": "Overnight care", "description": "A familiar, cozy routine while you are away.", "price": "$65 / night", "items": ["Evening & morning walks", "Feeding routine", "24-hour supervision"] }], "services": [{ "title": "Dog walking", "description": "One-on-one neighborhood walks with photo updates and water breaks.", "price": "$25 / walk", "items": ["GPS-tracked routes", "Flexible scheduling", "Post-walk notes"] }, { "title": "Grooming", "description": "A calmer, kinder grooming day for dogs of every size.", "price": "From $40", "items": ["Bathing & blow-dry", "Nail and ear care", "Full groom available"] }, { "title": "Overnight care", "description": "A familiar, cozy routine while you are away.", "price": "$65 / night", "items": ["Evening & morning walks", "Feeding routine", "24-hour supervision"] }],
"reviews": [{ "quote": "Marcus sends the best updates. Our dog recognizes his car and is ready at the door.", "author": "Tom N.", "rating": 5 }, { "quote": "The grooming team made bath time stress-free for the first time ever.", "author": "Jennifer W.", "rating": 5 }, { "quote": "We came home to a relaxed, happy puppy. That peace of mind is everything.", "author": "Lisa P.", "rating": 5 }], "reviews": [{ "quote": "Marcus sends the best updates. Our dog recognizes his car and is ready at the door.", "author": "Tom N.", "rating": 5 }, { "quote": "The grooming team made bath time stress-free for the first time ever.", "author": "Jennifer W.", "rating": 5 }, { "quote": "We came home to a relaxed, happy puppy. That peace of mind is everything.", "author": "Lisa P.", "rating": 5 }],
"faq": [],
"story": { "heading": "A smaller pack. A more personal kind of care.", "copy": "Happy Tails began with a few neighborhood walks and a belief that every dog deserves to be known—not just scheduled. Today, our close-knit team brings the same attentive care to every walk, groom, and sleepover.", "image": "/images/happy-tails-team.jpg", "imageAlt": "Happy Tails team with dogs in the park" }, "story": { "heading": "A smaller pack. A more personal kind of care.", "copy": "Happy Tails began with a few neighborhood walks and a belief that every dog deserves to be known—not just scheduled. Today, our close-knit team brings the same attentive care to every walk, groom, and sleepover.", "image": "/images/happy-tails-team.jpg", "imageAlt": "Happy Tails team with dogs in the park" },
"team": [{ "name": "Sarah Mitchell", "role": "Founder & Head Groomer", "bio": "A gentle grooming specialist with ten years of experience.", "image": "/images/sarah-mitchell.jpg" }, { "name": "Marcus Rivera", "role": "Lead Trainer", "bio": "Positive-reinforcement expert and champion pack leader.", "image": "/images/marcus-rivera.jpg" }, { "name": "Emily Chen", "role": "Pack Walker", "bio": "A marathon runner who keeps every pup moving and smiling.", "image": "/images/emily-chen.jpg" }, { "name": "David Okonkwo", "role": "Overnight Care", "bio": "The calm, caring presence behind cozy overnight stays.", "image": "/images/david-okonkwo.jpg" }] "team": [{ "name": "Sarah Mitchell", "role": "Founder & Head Groomer", "bio": "A gentle grooming specialist with ten years of experience.", "image": "/images/sarah-mitchell.jpg" }, { "name": "Marcus Rivera", "role": "Lead Trainer", "bio": "Positive-reinforcement expert and champion pack leader.", "image": "/images/marcus-rivera.jpg" }, { "name": "Emily Chen", "role": "Pack Walker", "bio": "A marathon runner who keeps every pup moving and smiling.", "image": "/images/emily-chen.jpg" }, { "name": "David Okonkwo", "role": "Overnight Care", "bio": "The calm, caring presence behind cozy overnight stays.", "image": "/images/david-okonkwo.jpg" }]
} }
+4 -2
View File
@@ -1,7 +1,9 @@
--- ---
const { heading = 'Frequently asked questions', items = [] } = Astro.props; import { render } from 'astro:content';
const { heading = 'Frequently asked questions', entries = [] } = Astro.props;
const items = await Promise.all(entries.map(async (entry) => ({ ...entry.data, Content: (await render(entry)).Content })));
if (!items.length) return; if (!items.length) return;
--- ---
<section class="section faq-section" aria-labelledby="faq-heading"> <section class="section faq-section" aria-labelledby="faq-heading">
<div class="shell narrow"><p class="eyebrow">Helpful answers</p><h2 id="faq-heading">{heading}</h2><div class="faq-list">{items.map((item) => <details><summary>{item.question}</summary><p>{item.answer}</p></details>)}</div></div> <div class="shell narrow"><p class="eyebrow">Helpful answers</p><h2 id="faq-heading">{heading}</h2><div class="faq-list">{items.map((item) => <details><summary>{item.question}</summary><div class="faq-answer"><item.Content /></div></details>)}</div></div>
</section> </section>
+26
View File
@@ -0,0 +1,26 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ base: './sites/blog/content/posts', pattern: '**/*.md' }),
schema: z.object({
title: z.string().min(12).max(100),
description: z.string().min(30).max(180),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string().min(2).max(32)).min(1).max(4),
draft: z.boolean().default(false),
}),
});
const faq = defineCollection({
loader: glob({ base: './sites/www/content/faq', pattern: '**/*.md' }),
schema: z.object({
question: z.string().min(12).max(180),
category: z.string().min(2).max(48).default('General'),
order: z.number().int().min(0).max(99).default(0),
draft: z.boolean().default(false),
}),
});
export const collections = { blog, faq };
+5 -2
View File
@@ -1,14 +1,17 @@
--- ---
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';
import Faq from '../components/Faq.astro'; import Faq from '../components/Faq.astro';
import Hero from '../components/Hero.astro'; import Hero from '../components/Hero.astro';
import { isBlog, site } from '../lib/site-data.js'; import { isBlog, site } from '../lib/site-data.js';
const posts = isBlog ? (await getCollection('blog', ({ data }) => !data.draft)).sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()) : [];
const faqs = !isBlog ? (await getCollection('faq', ({ data }) => !data.draft)).sort((a, b) => a.data.order - b.data.order) : [];
--- ---
<BaseLayout> <BaseLayout>
<Hero /> <Hero />
{isBlog ? <section class="section"><div class="shell"><div class="section-heading"><p class="eyebrow">The journal</p><h2>Small notes for happier dog days.</h2></div><div class="post-grid">{site.posts.map((post) => <article class="post-card"><p class="tag">{post.tag}</p><h3>{post.title}</h3><p>{post.excerpt}</p><footer>{post.date}</footer></article>)}</div><p class="phase-note">This static sample deliberately uses JSON. Markdown publishing starts only in the later, separately approved content-update phase.</p></div></section> : <> {isBlog ? <section class="section"><div class="shell"><div class="section-heading"><p class="eyebrow">The journal</p><h2>Small notes for happier dog days.</h2></div><div class="post-grid">{posts.map((post) => <article class="post-card"><p class="tag">{post.data.tags.join(' · ')}</p><h3><a href={`/posts/${post.id}/`}>{post.data.title}</a></h3><p>{post.data.description}</p><footer>{post.data.pubDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' })}</footer></article>)}</div><p class="phase-note">This static sample now uses Markdown with validated frontmatter. Draft entries stay out of the journal and sitemap.</p></div></section> : <>
<section class="section"><div class="shell"><div class="section-heading split"><div><p class="eyebrow">The Happy Tails difference</p><h2>A familiar face, a thoughtful routine, a very happy dog.</h2></div><p>We keep our pack intentionally small, so every dog gets the calm attention and consistent care they deserve.</p></div><div class="feature-grid">{site.features.map((feature) => <article class="feature"><span>{feature.icon}</span><h3>{feature.title}</h3><p>{feature.description}</p></article>)}</div></div></section> <section class="section"><div class="shell"><div class="section-heading split"><div><p class="eyebrow">The Happy Tails difference</p><h2>A familiar face, a thoughtful routine, a very happy dog.</h2></div><p>We keep our pack intentionally small, so every dog gets the calm attention and consistent care they deserve.</p></div><div class="feature-grid">{site.features.map((feature) => <article class="feature"><span>{feature.icon}</span><h3>{feature.title}</h3><p>{feature.description}</p></article>)}</div></div></section>
<section class="section surface"><div class="shell story-grid"><img src={site.story.image} alt={site.story.imageAlt} loading="lazy" /><div><p class="eyebrow">Our story</p><h2>{site.story.heading}</h2><p class="lede">{site.story.copy}</p><a class="text-link" href="/about">Meet the team →</a></div></div></section> <section class="section surface"><div class="shell story-grid"><img src={site.story.image} alt={site.story.imageAlt} loading="lazy" /><div><p class="eyebrow">Our story</p><h2>{site.story.heading}</h2><p class="lede">{site.story.copy}</p><a class="text-link" href="/about">Meet the team →</a></div></div></section>
<section class="section"><div class="shell"><div class="section-heading"><p class="eyebrow">Loved by local families</p><h2>Five-star care, shared in their own words.</h2></div><div class="review-grid">{site.reviews.map((review) => <blockquote class="review"><div aria-label={`${review.rating} out of 5 stars`} class="stars">★★★★★</div><p>“{review.quote}”</p><footer>{review.author} <span>Google review</span></footer></blockquote>)}</div><p class="review-disclosure">Review cards are a presentation pattern. Before launch, display only reviews approved for reuse and preserve their source, date, rating, and attribution under the business’s Google review policy.</p></div></section><Faq items={site.faq} /> <section class="section"><div class="shell"><div class="section-heading"><p class="eyebrow">Loved by local families</p><h2>Five-star care, shared in their own words.</h2></div><div class="review-grid">{site.reviews.map((review) => <blockquote class="review"><div aria-label={`${review.rating} out of 5 stars`} class="stars">★★★★★</div><p>“{review.quote}”</p><footer>{review.author} <span>Google review</span></footer></blockquote>)}</div><p class="review-disclosure">Review cards are a presentation pattern. Before launch, display only reviews approved for reuse and preserve their source, date, rating, and attribution under the business’s Google review policy.</p></div></section><Faq entries={faqs} />
</>} </>}
</BaseLayout> </BaseLayout>
+17
View File
@@ -0,0 +1,17 @@
---
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
export async function getStaticPaths() {
if (process.env.SITE_KEY !== 'blog') return [];
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}
const { post } = Astro.props;
const { Content } = await render(post);
const published = post.data.pubDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
---
<BaseLayout title={`${post.data.title} | The Happy Tails Journal`} description={post.data.description}>
<article class="article"><div class="shell narrow"><p class="eyebrow">{post.data.tags.join(' · ')}</p><h1>{post.data.title}</h1><p class="lede">{post.data.description}</p><p class="article-date">Published {published}</p><div class="article-body"><Content /></div><a class="text-link" href="/">← Back to the journal</a></div></article>
</BaseLayout>
+2 -2
View File
@@ -50,7 +50,7 @@ nav a[aria-current='page'], nav a:hover { color: var(--ink); }
.centered { text-align: center; }.centered .lede { margin-inline: auto; } .centered { text-align: center; }.centered .lede { margin-inline: auto; }
.contact-grid { align-items: start; }.contact-details { margin-top: 2rem; font-style: normal; color: #52616b; }.contact-details p { margin-bottom: .8rem; }.contact-details a { color: var(--ink); font-weight: 700; }.contact-form { display: grid; gap: 1rem; padding: 2rem; background: var(--surface); border-radius: .85rem; }.contact-form label { display: grid; gap: .4rem; font-size: .9rem; font-weight: 700; }.contact-form input,.contact-form textarea { width: 100%; padding: .8rem; font: inherit; border: 1px solid #cbd5d1; border-radius: .35rem; background: #fff; }.contact-form button:disabled { cursor: not-allowed; opacity: .6; }.contact-form p { margin-bottom: 0; color: #66747d; font-size: .85rem; } .contact-grid { align-items: start; }.contact-details { margin-top: 2rem; font-style: normal; color: #52616b; }.contact-details p { margin-bottom: .8rem; }.contact-details a { color: var(--ink); font-weight: 700; }.contact-form { display: grid; gap: 1rem; padding: 2rem; background: var(--surface); border-radius: .85rem; }.contact-form label { display: grid; gap: .4rem; font-size: .9rem; font-weight: 700; }.contact-form input,.contact-form textarea { width: 100%; padding: .8rem; font: inherit; border: 1px solid #cbd5d1; border-radius: .35rem; background: #fff; }.contact-form button:disabled { cursor: not-allowed; opacity: .6; }.contact-form p { margin-bottom: 0; color: #66747d; font-size: .85rem; }
.location-map { display: grid; grid-template-columns: .75fr 1.25fr; overflow: hidden; border: 1px solid #dce4e1; border-radius: 1rem; background: #fff; }.map-copy { padding: clamp(2rem, 5vw, 4rem); }.map-copy h3 { font-family: 'DM Serif Display', Georgia, serif; font-size: 2.25rem; font-weight: 400; line-height: 1.05; }.map-copy p:not(.eyebrow) { color: #52616b; }.location-map iframe { display: block; min-height: 360px; width: 100%; border: 0; filter: saturate(.8) contrast(.96); } .location-map { display: grid; grid-template-columns: .75fr 1.25fr; overflow: hidden; border: 1px solid #dce4e1; border-radius: 1rem; background: #fff; }.map-copy { padding: clamp(2rem, 5vw, 4rem); }.map-copy h3 { font-family: 'DM Serif Display', Georgia, serif; font-size: 2.25rem; font-weight: 400; line-height: 1.05; }.map-copy p:not(.eyebrow) { color: #52616b; }.location-map iframe { display: block; min-height: 360px; width: 100%; border: 0; filter: saturate(.8) contrast(.96); }
.post-card { padding: 2rem; border-radius: .85rem; background: #fff; border: 1px solid #dce4e1; }.post-card h3 { font-family: 'DM Serif Display', Georgia, serif; font-size: 1.75rem; font-weight: 400; line-height: 1.1; }.post-card footer { color: #75828c; font-size: .85rem; }.not-found { min-height: 55vh; display: grid; place-items: center; } .post-card { padding: 2rem; border-radius: .85rem; background: #fff; border: 1px solid #dce4e1; }.post-card h3 { font-family: 'DM Serif Display', Georgia, serif; font-size: 1.75rem; font-weight: 400; line-height: 1.1; }.post-card h3 a { text-decoration: none; }.post-card h3 a:hover { color: var(--accent); }.post-card footer { color: #75828c; font-size: .85rem; }.not-found { min-height: 55vh; display: grid; place-items: center; }
.narrow { width: min(760px, calc(100% - 3rem)); }.faq-list { border-top: 1px solid #dce4e1; }.faq-list details { border-bottom: 1px solid #dce4e1; padding: 1.1rem 0; }.faq-list summary { cursor: pointer; font-weight: 700; list-style: none; padding-right: 2rem; position: relative; }.faq-list summary::after { content: '+'; position: absolute; right: .25rem; color: var(--accent); font-size: 1.3rem; top: -.3rem; }.faq-list details[open] summary::after { content: '−'; }.faq-list p { margin: 1rem 2rem .2rem 0; color: #52616b; } .narrow { width: min(760px, calc(100% - 3rem)); }.faq-list { border-top: 1px solid #dce4e1; }.faq-list details { border-bottom: 1px solid #dce4e1; padding: 1.1rem 0; }.faq-list summary { cursor: pointer; font-weight: 700; list-style: none; padding-right: 2rem; position: relative; }.faq-list summary::after { content: '+'; position: absolute; right: .25rem; color: var(--accent); font-size: 1.3rem; top: -.3rem; }.faq-list details[open] summary::after { content: '−'; }.faq-answer > :first-child { margin-top: 1rem; }.faq-answer > :last-child { margin-bottom: .2rem; }.faq-answer p,.faq-answer li { color: #52616b; }.article { padding: clamp(4rem, 8vw, 7rem) 0; }.article h1 { max-width: 14ch; font-size: clamp(3rem, 6vw, 5.8rem); }.article-date { color: #75828c; font-size: .9rem; }.article-body { margin: 2.5rem 0; color: #344650; font-size: 1.07rem; }.article-body h2 { margin-top: 2.5rem; font-size: clamp(1.8rem, 3vw, 2.8rem); }.article-body li { margin-bottom: .55rem; }
.site-footer { padding: 4rem 0 1.25rem; background: var(--ink); color: #fff; }.site-footer .brand { color: #fff; }.site-footer p { color: #c5d0d6; max-width: 34ch; }.footer-grid { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 3rem; }.footer-link { display: block; margin: .6rem 0; color: #fff; text-decoration: none; }.footer-bottom { display: flex; justify-content: space-between; gap: 1rem; margin-top: 3rem; padding-top: 1.25rem; border-top: 1px solid #35505f; color: #c5d0d6; font-size: .85rem; } .site-footer { padding: 4rem 0 1.25rem; background: var(--ink); color: #fff; }.site-footer .brand { color: #fff; }.site-footer p { color: #c5d0d6; max-width: 34ch; }.footer-grid { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 3rem; }.footer-link { display: block; margin: .6rem 0; color: #fff; text-decoration: none; }.footer-bottom { display: flex; justify-content: space-between; gap: 1rem; margin-top: 3rem; padding-top: 1.25rem; border-top: 1px solid #35505f; color: #c5d0d6; font-size: .85rem; }
@media (max-width: 760px) { .shell { width: min(100% - 2rem, 1120px); }.menu-button { display: inline-flex; gap: .5rem; } .nav-wrap { min-height: 68px; } nav { display: none; position: absolute; top: 68px; right: 1rem; left: 1rem; padding: 1rem; background: #fff; border: 1px solid #e8ecea; border-radius: .6rem; box-shadow: 0 1rem 2rem #102a4320; } nav.is-open { display: grid; } .hero { background: var(--surface); }.hero-grid,.story-grid,.contact-grid,.section-heading.split,.page-intro-grid,.location-map { grid-template-columns: 1fr; gap: 2.5rem; }.page-intro img { max-width: 520px; }.hero-image { max-width: 520px; }.image-note { right: -.5rem; }.feature-grid,.review-grid,.service-grid,.post-grid { grid-template-columns: 1fr; }.service-card.featured,.service-card.featured:hover { transform: none; }.team-grid { grid-template-columns: 1fr; }.location-map iframe { min-height: 300px; }.footer-grid { grid-template-columns: 1fr; gap: 1.5rem; }.footer-bottom { display: block; }.footer-bottom span { display: block; margin-top: .5rem; } } @media (max-width: 760px) { .shell { width: min(100% - 2rem, 1120px); }.menu-button { display: inline-flex; gap: .5rem; } .nav-wrap { min-height: 68px; } nav { display: none; position: absolute; top: 68px; right: 1rem; left: 1rem; padding: 1rem; background: #fff; border: 1px solid #e8ecea; border-radius: .6rem; box-shadow: 0 1rem 2rem #102a4320; } nav.is-open { display: grid; } .hero { background: var(--surface); }.hero-grid,.story-grid,.contact-grid,.section-heading.split,.page-intro-grid,.location-map { grid-template-columns: 1fr; gap: 2.5rem; }.page-intro img { max-width: 520px; }.hero-image { max-width: 520px; }.image-note { right: -.5rem; }.feature-grid,.review-grid,.service-grid,.post-grid { grid-template-columns: 1fr; }.service-card.featured,.service-card.featured:hover { transform: none; }.team-grid { grid-template-columns: 1fr; }.location-map iframe { min-height: 300px; }.footer-grid { grid-template-columns: 1fr; gap: 1.5rem; }.footer-bottom { display: block; }.footer-bottom span { display: block; margin-top: .5rem; } }