Add static SEO and FAQ foundations

This commit is contained in:
Kevin
2026-09-02 01:56:51 -04:00
parent 19bba4282e
commit 245f0e8a7e
10 changed files with 53 additions and 4 deletions
+6 -1
View File
@@ -7,9 +7,14 @@ that data without a per-site code fork.
Current reusable primitives include the base SEO layout, header/footer,
full-page hero, section heading, CTA band, review cards, and optional location
map. A real logo can be provided as `/images/<safe-name>.svg` or image asset;
map. The optional `faq` array provides accessible, static question/answer
accordions and stays empty until approved content exists. A real logo can be provided as `/images/<safe-name>.svg` or image asset;
until then the configured short mark and shared favicon are used.
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
will reject malformed site JSON and oversize source images.
Each selected build also generates `sitemap.xml` and `robots.txt`. Preview
builds disallow indexing; a future approved production build must set
`DEPLOY_ENV=production` to publish crawlable robots rules and the sitemap URL.
+2
View File
@@ -17,6 +17,7 @@
"features": { "type": "array", "maxItems": 12, "items": { "$ref": "#/$defs/card" } },
"services": { "type": "array", "maxItems": 24, "items": { "$ref": "#/$defs/service" } },
"reviews": { "type": "array", "maxItems": 12, "items": { "$ref": "#/$defs/review" } },
"faq": { "type": "array", "maxItems": 20, "items": { "$ref": "#/$defs/faq" } },
"story": { "$ref": "#/$defs/story" },
"team": { "type": "array", "maxItems": 20, "items": { "$ref": "#/$defs/team" } },
"posts": { "type": "array", "maxItems": 24, "items": { "$ref": "#/$defs/post" } }
@@ -33,6 +34,7 @@
"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" } } } },
"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" } } },
"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" } } }
+3
View File
@@ -11,3 +11,6 @@ const result = spawnSync('npm', ['run', 'build', '--', '--outDir', `dist/${key}`
stdio: 'inherit'
});
if (result.status !== 0) process.exit(result.status ?? 1);
const seo = spawnSync('node', ['scripts/generate-seo.mjs'], { env: { ...process.env, SITE_KEY: key }, stdio: 'inherit' });
if (seo.status !== 0) process.exit(seo.status ?? 1);
+28
View File
@@ -0,0 +1,28 @@
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { join, relative, sep } from 'node:path';
const key = process.env.SITE_KEY;
if (!key) throw new Error('SITE_KEY is required.');
const manifest = JSON.parse(await readFile('sites.json', 'utf8'));
const deployment = manifest.sites[key];
if (!deployment) throw new Error(`Unknown site key: ${key}`);
const outputDir = `dist/${key}`;
async function htmlFiles(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const nested = await Promise.all(entries.map(async (entry) => entry.isDirectory() ? htmlFiles(join(dir, entry.name)) : entry.name.endsWith('.html') ? [join(dir, entry.name)] : []));
return nested.flat();
}
const urls = (await Promise.all((await htmlFiles(outputDir)).map(async (file) => {
const html = await readFile(file, 'utf8');
if (/<title>Redirecting to:/i.test(html)) return null;
const path = relative(outputDir, file).split(sep).join('/');
if (path === '404.html') return null;
return path === 'index.html' ? '/' : `/${path.replace(/index\.html$/, '')}`;
}))).filter(Boolean).sort();
const origin = `https://${deployment.hostname}`;
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((path) => ` <url><loc>${origin}${path}</loc></url>`).join('\n')}\n</urlset>\n`;
const robots = process.env.DEPLOY_ENV === 'production' ? `User-agent: *\nAllow: /\nSitemap: ${origin}/sitemap.xml\n` : 'User-agent: *\nDisallow: /\n';
await Promise.all([writeFile(join(outputDir, 'sitemap.xml'), sitemap), writeFile(join(outputDir, 'robots.txt'), robots)]);
console.log(`Generated ${urls.length} sitemap URLs and ${process.env.DEPLOY_ENV === 'production' ? 'production' : 'preview'} robots rules for ${key}.`);
+1 -1
View File
@@ -4,7 +4,7 @@ import { resolve } from 'node:path';
const key = process.env.SITE_KEY;
if (!key) throw new Error('SITE_KEY is required.');
const root = resolve(`dist/${key}`);
const required = ['index.html', '404.html'];
const required = ['index.html', '404.html', 'robots.txt', 'sitemap.xml'];
if (key === 'www') required.push('about/index.html', 'services/index.html', 'contact/index.html');
for (const file of required) await access(resolve(root, file));
+2 -1
View File
@@ -6,5 +6,6 @@
"seo": { "socialImage": "/images/puppy-playing.jpg" },
"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": "/" },
"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" }]
"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": []
}
+1
View File
@@ -11,6 +11,7 @@
"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"] }],
"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" },
"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" }]
}
+7
View File
@@ -0,0 +1,7 @@
---
const { heading = 'Frequently asked questions', items = [] } = Astro.props;
if (!items.length) return;
---
<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>
</section>
+2 -1
View File
@@ -1,5 +1,6 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import Faq from '../components/Faq.astro';
import Hero from '../components/Hero.astro';
import { isBlog, site } from '../lib/site-data.js';
---
@@ -8,6 +9,6 @@ import { isBlog, site } from '../lib/site-data.js';
{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> : <>
<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"><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>
<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} />
</>}
</BaseLayout>
+1
View File
@@ -51,5 +51,6 @@ nav a[aria-current='page'], nav a:hover { color: var(--ink); }
.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); }
.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; }
.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; }
.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; } }