Add Markdown blog and FAQ content collections
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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" } } }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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; } }
|
||||||
|
|||||||
Reference in New Issue
Block a user