diff --git a/docs/content-model.md b/docs/content-model.md new file mode 100644 index 0000000..2481f47 --- /dev/null +++ b/docs/content-model.md @@ -0,0 +1,15 @@ +# Static site content model + +Each hosted site has a validated `sites//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/.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. diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..a41108e --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/schemas/site.schema.json b/schemas/site.schema.json new file mode 100644 index 0000000..633fc9d --- /dev/null +++ b/schemas/site.schema.json @@ -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" } } } + } +} diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 6e51ca2..038aad6 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -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.'); diff --git a/sites/blog/site.json b/sites/blog/site.json index 89386a4..4e56984 100644 --- a/sites/blog/site.json +++ b/sites/blog/site.json @@ -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" }] diff --git a/sites/www/site.json b/sites/www/site.json index b86fa72..098a649 100644 --- a/sites/www/site.json +++ b/sites/www/site.json @@ -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" }, diff --git a/src/components/CallToAction.astro b/src/components/CallToAction.astro new file mode 100644 index 0000000..d134ff3 --- /dev/null +++ b/src/components/CallToAction.astro @@ -0,0 +1,4 @@ +--- +const { eyebrow, heading, copy, href, label } = Astro.props; +--- +

{eyebrow}

{heading}

{copy}

{label}
diff --git a/src/components/Footer.astro b/src/components/Footer.astro index e1b092e..4bb0a45 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -3,7 +3,7 @@ import { isBlog, site } from '../lib/site-data.js'; ---