Add static SEO and FAQ foundations
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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" } } }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}.`);
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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" }]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
Reference in New Issue
Block a user