Strengthen reusable Astro site foundation

This commit is contained in:
Kevin
2026-09-02 01:47:27 -04:00
parent 7557fa47b9
commit 19bba4282e
13 changed files with 101 additions and 16 deletions
+15
View File
@@ -0,0 +1,15 @@
# Static site content model
Each hosted site has a validated `sites/<key>/site.json`. It owns its brand
fallback/logo, SEO image, navigation, page content, contact information, and
whether a physical-address map is displayed. Shared Astro components render
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;
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.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Site mark"><rect width="64" height="64" rx="18" fill="#102a43"/><path fill="#f59e0b" d="M32 46c-9 0-15-5-15-12 0-5 3-9 7-9 3 0 5 2 8 4 3-2 5-4 8-4 4 0 7 4 7 9 0 7-6 12-15 12ZM20 22c-4 0-7-3-7-7s3-7 7-7 7 3 7 7-3 7-7 7Zm24 0c-4 0-7-3-7-7s3-7 7-7 7 3 7 7-3 7-7 7Z"/></svg>

After

Width:  |  Height:  |  Size: 354 B

+40
View File
@@ -0,0 +1,40 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sloanops.com/schemas/site.schema.json",
"type": "object",
"additionalProperties": false,
"required": ["name", "description", "theme", "branding", "seo", "nav", "hero"],
"properties": {
"name": { "type": "string", "minLength": 1, "maxLength": 120 },
"description": { "type": "string", "minLength": 1, "maxLength": 200 },
"analyticsId": { "type": "string", "pattern": "^G-[A-Z0-9]+$" },
"theme": { "$ref": "#/$defs/theme" },
"branding": { "$ref": "#/$defs/branding" },
"seo": { "$ref": "#/$defs/seo" },
"nav": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "$ref": "#/$defs/navItem" } },
"contact": { "$ref": "#/$defs/contact" },
"hero": { "$ref": "#/$defs/hero" },
"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" } },
"story": { "$ref": "#/$defs/story" },
"team": { "type": "array", "maxItems": 20, "items": { "$ref": "#/$defs/team" } },
"posts": { "type": "array", "maxItems": 24, "items": { "$ref": "#/$defs/post" } }
},
"$defs": {
"text": { "type": "string", "minLength": 1, "maxLength": 1000 },
"imagePath": { "type": "string", "pattern": "^/images/[a-z0-9][a-z0-9-]*\\.(jpg|jpeg|png|webp|svg)$" },
"theme": { "type": "object", "additionalProperties": false, "required": ["accent", "ink", "surface"], "properties": { "accent": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }, "ink": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }, "surface": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" } } },
"branding": { "type": "object", "additionalProperties": false, "required": ["wordmark", "mark", "logo"], "properties": { "wordmark": { "type": "string", "minLength": 1, "maxLength": 48 }, "mark": { "type": "string", "minLength": 1, "maxLength": 3 }, "logo": { "anyOf": [{ "$ref": "#/$defs/imagePath" }, { "type": "null" }] } } },
"seo": { "type": "object", "additionalProperties": false, "required": ["socialImage"], "properties": { "socialImage": { "$ref": "#/$defs/imagePath" }, "locality": { "type": "string", "maxLength": 120 } } },
"navItem": { "type": "object", "additionalProperties": false, "required": ["label", "href"], "properties": { "label": { "type": "string", "minLength": 1, "maxLength": 32 }, "href": { "type": "string", "pattern": "^(?:/|https://)" } } },
"contact": { "type": "object", "additionalProperties": false, "required": ["email", "phone", "phoneHref", "address", "hours", "showMap"], "properties": { "email": { "type": "string", "format": "email" }, "phone": { "type": "string", "minLength": 7, "maxLength": 30 }, "phoneHref": { "type": "string", "pattern": "^\\+[0-9]{7,20}$" }, "address": { "type": "string", "minLength": 1, "maxLength": 240 }, "hours": { "type": "string", "maxLength": 120 }, "showMap": { "type": "boolean" } } },
"hero": { "type": "object", "additionalProperties": false, "required": ["eyebrow", "headline", "copy", "image", "imageAlt", "cta", "ctaHref"], "properties": { "eyebrow": { "$ref": "#/$defs/text" }, "headline": { "type": "string", "minLength": 1, "maxLength": 160 }, "copy": { "$ref": "#/$defs/text" }, "image": { "$ref": "#/$defs/imagePath" }, "imageAlt": { "$ref": "#/$defs/text" }, "cta": { "type": "string", "minLength": 1, "maxLength": 64 }, "ctaHref": { "type": "string", "pattern": "^(?:/|https://)" } } },
"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 } } },
"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" } } }
}
}
+20 -12
View File
@@ -1,22 +1,30 @@
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
import { readFile } from 'node:fs/promises';
import { readdir, readFile, stat } from 'node:fs/promises';
import { join } from 'node:path';
const readJson = async (path) => JSON.parse(await readFile(path, 'utf8'));
const ajv = new Ajv2020({ allErrors: true, strict: true });
addFormats(ajv);
const [intakeSchema, sitesSchema, validIntake, invalidIntake, sites] = await Promise.all([
readJson('schemas/intake.v1.schema.json'),
readJson('schemas/sites.schema.json'),
readJson('fixtures/intake.v1.valid.json'),
readJson('fixtures/intake.v1.invalid.json'),
readJson('sites.json')
const [intakeSchema, sitesSchema, siteSchema, validIntake, invalidIntake, sites] = await Promise.all([
readJson('schemas/intake.v1.schema.json'), readJson('schemas/sites.schema.json'), readJson('schemas/site.schema.json'),
readJson('fixtures/intake.v1.valid.json'), readJson('fixtures/intake.v1.invalid.json'), readJson('sites.json')
]);
const validIntakeCheck = ajv.compile(intakeSchema);
const intakeCheck = ajv.compile(intakeSchema);
const sitesCheck = ajv.compile(sitesSchema);
if (!validIntakeCheck(validIntake)) throw new Error(`Valid intake rejected: ${ajv.errorsText(validIntakeCheck.errors)}`);
if (validIntakeCheck(invalidIntake)) throw new Error('Invalid intake fixture was accepted');
const siteCheck = ajv.compile(siteSchema);
if (!intakeCheck(validIntake)) throw new Error(`Valid intake rejected: ${ajv.errorsText(intakeCheck.errors)}`);
if (intakeCheck(invalidIntake)) throw new Error('Invalid intake fixture was accepted');
if (!sitesCheck(sites)) throw new Error(`Sites manifest rejected: ${ajv.errorsText(sitesCheck.errors)}`);
console.log('Schema fixtures and sites manifest are valid.');
for (const key of Object.keys(sites.sites)) {
const data = await readJson(`sites/${key}/site.json`);
if (!siteCheck(data)) throw new Error(`${key} site data rejected: ${ajv.errorsText(siteCheck.errors)}`);
}
for (const file of await readdir('public/images')) {
const info = await stat(join('public/images', file));
if (info.size > 500 * 1024) throw new Error(`Image exceeds the 500 KiB pilot limit: ${file}`);
}
console.log('Intake fixtures, deployment manifest, site data, and image limits are valid.');
+2
View File
@@ -2,6 +2,8 @@
"name": "The Happy Tails Journal",
"description": "Simple, useful dog-care ideas from the Happy Tails pack.",
"theme": { "accent": "#0f766e", "ink": "#102a43", "surface": "#f3f8f6" },
"branding": { "wordmark": "Happy Tails Journal", "mark": "HJ", "logo": null },
"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" }]
+2
View File
@@ -3,6 +3,8 @@
"description": "Personal dog walking, grooming, and overnight care for Springfield families.",
"analyticsId": "G-Z15EG3SFPD",
"theme": { "accent": "#f59e0b", "ink": "#102a43", "surface": "#f7f5ef" },
"branding": { "wordmark": "Happy Tails", "mark": "HT", "logo": null },
"seo": { "socialImage": "/images/hero-dogs.jpg", "locality": "Springfield, IL" },
"nav": [{ "label": "Home", "href": "/" }, { "label": "About", "href": "/about" }, { "label": "Services", "href": "/services" }, { "label": "Contact", "href": "/contact" }],
"contact": { "email": "hello@3031023.xyz", "phone": "(555) 742-9283", "phoneHref": "+15557429283", "address": "742 Evergreen Terrace, Springfield, IL 62704", "hours": "Mon–Fri · 7am–6pm CST", "showMap": true },
"hero": { "eyebrow": "Springfield's neighborhood dog care", "headline": "Care that feels like family.", "copy": "Thoughtful walks, gentle grooming, and cozy overnight stays for the dog you love most.", "image": "/images/hero-dogs.jpg", "imageAlt": "Two happy dogs playing outdoors", "cta": "Meet the pack", "ctaHref": "/contact" },
+4
View File
@@ -0,0 +1,4 @@
---
const { eyebrow, heading, copy, href, label } = Astro.props;
---
<section class="section surface"><div class="shell centered"><p class="eyebrow">{eyebrow}</p><h2>{heading}</h2><p class="lede">{copy}</p><a class="button" href={href}>{label} <span aria-hidden="true">→</span></a></div></section>
+1 -1
View File
@@ -3,7 +3,7 @@ import { isBlog, site } from '../lib/site-data.js';
---
<footer class="site-footer">
<div class="shell footer-grid">
<div><a class="brand" href="/"><span class="brand-mark" aria-hidden="true">✦</span><span>{isBlog ? 'Happy Tails Journal' : 'Happy Tails'}</span></a><p>{site.description}</p></div>
<div><a class="brand" href="/">{site.branding.logo ? <img class="brand-logo" src={site.branding.logo} alt="" /> : <span class="brand-mark" aria-hidden="true">{site.branding.mark}</span>}<span>{site.branding.wordmark}</span></a><p>{site.description}</p></div>
<div><p class="eyebrow">Explore</p>{site.nav.map((item) => <a class="footer-link" href={item.href}>{item.label}</a>)}</div>
{!isBlog && <address><p class="eyebrow">Say hello</p><a class="footer-link" href={`tel:${site.contact.phoneHref}`}>{site.contact.phone}</a><a class="footer-link" href={`mailto:${site.contact.email}`}>{site.contact.email}</a></address>}
</div>
+1 -1
View File
@@ -4,7 +4,7 @@ const path = Astro.url.pathname;
---
<header class="site-header">
<div class="shell nav-wrap">
<a class="brand" href="/" aria-label={`${site.name} home`}><span class="brand-mark" aria-hidden="true">✦</span><span>{isBlog ? 'Happy Tails Journal' : 'Happy Tails'}</span></a>
<a class="brand" href="/" aria-label={`${site.name} home`}>{site.branding.logo ? <img class="brand-logo" src={site.branding.logo} alt="" /> : <span class="brand-mark" aria-hidden="true">{site.branding.mark}</span>}<span>{site.branding.wordmark}</span></a>
<button class="menu-button" aria-expanded="false" aria-controls="main-menu">Menu <span aria-hidden="true">+</span></button>
<nav id="main-menu" aria-label="Main navigation">
{site.nav.map((item) => <a href={item.href} aria-current={path === item.href ? 'page' : undefined}>{item.label}</a>)}
+4
View File
@@ -0,0 +1,4 @@
---
const { eyebrow, heading, copy, split = false } = Astro.props;
---
<div class:list={['section-heading', { split }]}><div><p class="eyebrow">{eyebrow}</p><h2>{heading}</h2></div>{copy && <p>{copy}</p>}</div>
+8 -1
View File
@@ -2,9 +2,11 @@
import '../styles/global.css';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import { deployment, isProduction, site } from '../lib/site-data.js';
import { deployment, isBlog, isProduction, site } from '../lib/site-data.js';
const { title = site.name, description = site.description } = Astro.props;
const canonical = `https://${deployment.hostname}${Astro.url.pathname}`;
const socialImage = new URL(site.seo.socialImage, `https://${deployment.hostname}`).href;
const structuredData = isBlog ? { '@context': 'https://schema.org', '@type': 'WebSite', name: site.name, url: `https://${deployment.hostname}` } : { '@context': 'https://schema.org', '@type': 'LocalBusiness', name: site.name, url: `https://${deployment.hostname}`, email: site.contact.email, telephone: site.contact.phoneHref, address: { '@type': 'PostalAddress', streetAddress: site.contact.address, addressLocality: site.seo.locality } };
---
<!doctype html>
<html lang="en">
@@ -17,12 +19,17 @@ const canonical = `https://${deployment.hostname}${Astro.url.pathname}`;
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={socialImage} />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>{title}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=DM+Serif+Display:ital@0;1&display=swap" rel="stylesheet" />
{isProduction && site.analyticsId && <script async src={`https://www.googletagmanager.com/gtag/js?id=${site.analyticsId}`}></script>}
{isProduction && site.analyticsId && <script define:vars={{ analyticsId: site.analyticsId }}>window.dataLayer = window.dataLayer || []; function gtag() { dataLayer.push(arguments); } gtag('js', new Date()); gtag('config', analyticsId);</script>}
<script type="application/ld+json" set:html={JSON.stringify(structuredData)} />
</head>
<body style={`--accent:${site.theme.accent};--ink:${site.theme.ink};--surface:${site.theme.surface}`}>
<Header />
+2 -1
View File
@@ -1,7 +1,8 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import CallToAction from '../components/CallToAction.astro';
import PageIntro from '../components/PageIntro.astro';
import { isBlog, site } from '../lib/site-data.js';
if (isBlog) return Astro.redirect('/');
---
<BaseLayout title={`Services | ${site.name}`}><PageIntro eyebrow="Services" title="Everything your dog needs for a brighter day." lede="Flexible, personal care—with no one-size-fits-all routines." image="/images/dog-grooming.jpg" imageAlt="A dog receiving gentle grooming care" /><section class="section"><div class="shell service-grid">{site.services.map((service, index) => <article class:list={['service-card', { featured: index === 1 }]}><p class="eyebrow">{service.price}</p><h2>{service.title}</h2><p>{service.description}</p><ul>{service.items.map((item) => <li>{item}</li>)}</ul><a class="text-link" href="/contact">Ask about {service.title.toLowerCase()} →</a></article>)}</div></section><section class="section surface"><div class="shell centered"><p class="eyebrow">A simple start</p><h2>Let's meet your dog first.</h2><p class="lede">Tell us a little about their routine, personality, and what would make their day better.</p><a class="button" href="/contact">Book a meet and greet →</a></div></section></BaseLayout>
<BaseLayout title={`Services | ${site.name}`}><PageIntro eyebrow="Services" title="Everything your dog needs for a brighter day." lede="Flexible, personal care—with no one-size-fits-all routines." image="/images/dog-grooming.jpg" imageAlt="A dog receiving gentle grooming care" /><section class="section"><div class="shell service-grid">{site.services.map((service, index) => <article class:list={['service-card', { featured: index === 1 }]}><p class="eyebrow">{service.price}</p><h2>{service.title}</h2><p>{service.description}</p><ul>{service.items.map((item) => <li>{item}</li>)}</ul><a class="text-link" href="/contact">Ask about {service.title.toLowerCase()} →</a></article>)}</div></section><CallToAction eyebrow="A simple start" heading="Let's meet your dog first." copy="Tell us a little about their routine, personality, and what would make their day better." href="/contact" label="Book a meet and greet" /></BaseLayout>
+1
View File
@@ -13,6 +13,7 @@ h3 { font-size: 1.2rem; margin-bottom: .5rem; }
.nav-wrap { min-height: 82px; display: flex; align-items: center; justify-content: space-between; gap: 2rem; }
.brand { display: inline-flex; align-items: center; gap: .5rem; text-decoration: none; font-size: 1.15rem; font-weight: 700; letter-spacing: -.03em; }
.brand-mark { display: grid; place-items: center; width: 28px; height: 28px; color: #fff; background: var(--accent); border-radius: 50%; font-size: .85rem; }
.brand-logo { width: 32px; height: 32px; object-fit: contain; }
nav { display: flex; align-items: center; gap: 1.5rem; }
nav a { color: #52616b; text-decoration: none; font-size: .92rem; font-weight: 600; }
nav a[aria-current='page'], nav a:hover { color: var(--ink); }