← back to Animals
tighten .gitignore: add missing standing-rule patterns (*.log dist/ build/ .next/)
a79a69bf74cf0c4272ab2238f8b341267fd34a69 · 2026-05-07 12:29:20 -0700 · Steve
Files touched
M .gitignoreM src/audit/generate_mockups.jsM src/scripts/build_subsites.js
Diff
commit a79a69bf74cf0c4272ab2238f8b341267fd34a69
Author: Steve <steve@designerwallcoverings.com>
Date: Thu May 7 12:29:20 2026 -0700
tighten .gitignore: add missing standing-rule patterns (*.log dist/ build/ .next/)
---
.gitignore | 6 +
src/audit/generate_mockups.js | 428 ++++++++++++++++++++++++++++++++++++------
src/scripts/build_subsites.js | 80 +++++---
3 files changed, 429 insertions(+), 85 deletions(-)
diff --git a/.gitignore b/.gitignore
index e4758be..0c1d2cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,9 @@ tmp/
# 2026-05-03: drop debug-loop log noise (was 3000+ untracked files)
logs/debug-loop-*/
logs/animals-debug-loop-*/
+
+# Standing-rule additions (2026-05-07)
+*.log
+dist/
+build/
+.next/
diff --git a/src/audit/generate_mockups.js b/src/audit/generate_mockups.js
index 6805899..4a93077 100644
--- a/src/audit/generate_mockups.js
+++ b/src/audit/generate_mockups.js
@@ -138,58 +138,297 @@ const STYLES = [
// Inject style into a CSS string by replacing tokens. Layouts call applyStyle()
// on their CSS to fold in the style tweaks without needing per-style logic.
function applyStyle(css, st) {
+ // Inject lang + viewport meta into every layout template at the doctype.
+ // Layouts hand-write their own <!doctype html><html><head>… preamble, so
+ // we patch it once here instead of editing 25 template strings.
return css
.replace(/\$RADIUS/g, st.radius + 'px')
.replace(/\$SHADOW/g, st.shadow)
.replace(/\$FWHERO/g, String(st.fwHero))
.replace(/\$LS/g, st.ls)
.replace(/\$PAD/g, st.pad)
- .replace(/\$PILL/g, st.pill + 'px');
+ .replace(/\$PILL/g, st.pill + 'px')
+ .replace(
+ /<!doctype html><html><head><meta charset=utf-8>/,
+ '<!doctype html><html lang="en"><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">'
+ );
}
-// ─── copy banks ───────────────────────────────────────────────────────────
-const HEADLINES = [
- 'Compassionate care, modern medicine.',
- 'A trusted name in your neighborhood.',
- 'Where great care begins.',
- 'Built around your pet, your family, your time.',
- 'Healthier visits. Honest pricing.',
-];
-const SUBS = [
- 'Caring for {city} families for over a decade.',
- 'Serving {city} with respect, transparency, and same-day availability.',
- 'Online booking. Email reminders. Real conversations with real clinicians.',
- 'Trusted by {city} for everyday care and the occasional emergency.',
- 'A small, attentive practice in the heart of {city}.',
-];
-const EYEBROWS = [
- 'Established practice', 'Now booking', 'Family-owned', 'Trusted in {city}', 'A new chapter',
-];
-const CTAS = [
- 'Book an appointment', 'Schedule a visit', 'Make an appointment', 'Request a time', 'Get on the schedule',
-];
-const SERVICE_SETS = [
- [
- { t: 'Wellness', p: 'Annual exams, vaccinations, preventive care plans.' },
- { t: 'Surgery & Dental', p: 'In-house procedures, same-day discharge for most.' },
- { t: 'Urgent care', p: 'Same-day sick visits — call before going to the ER.' },
- ],
- [
- { t: 'New patient visits', p: 'A real intake conversation, not a clipboard.' },
- { t: 'Specialty referrals',p: 'Dermatology, cardiology, oncology — we know who.' },
- { t: 'Senior care', p: 'Quarterly visit + lab panel for patients 8+.' },
- ],
- [
- { t: 'Diagnostics', p: 'On-site lab, digital radiology, ultrasound.' },
- { t: 'Pharmacy', p: 'Stocked formulary, refills you can pick up same day.' },
- { t: 'Care plans', p: 'Predictable monthly pricing for routine visits.' },
- ],
- [
- { t: 'Same-day visits', p: "Don't wait a week. Call by 10am, see us by 5pm." },
- { t: 'Behavior & training',p: 'In-clinic consultations and at-home plans.' },
- { t: 'House-call program', p: 'Visits at home for senior or anxious patients.' },
- ],
-];
+// ─── copy banks (gated by business category) ─────────────────────────────
+// Vet copy is the original bank; we maintain parallel banks for shelter,
+// kennel/boarding, retail (pet store / supplies / groomer), and a generic
+// fallback so non-vet categories never see "modern medicine" or "$95
+// wellness exam" copy.
+function categoryBucket(biz) {
+ const c = String(biz?.category || '').toLowerCase();
+ if (/vet|animal_hospital|veterin/.test(c)) return 'vet';
+ if (/shelter|humane|rescue|adopt|spca|aspca/.test(c)) return 'shelter';
+ if (/boarding|kennel|daycare|sitter|sitting|walker|walking/.test(c)) return 'boarding';
+ if (/groom/.test(c)) return 'grooming';
+ if (/store|supply|supplies|retail|feed|farm.?co.?op|tack|aquarium|pet.?country/.test(c)) return 'retail';
+ if (/train|training|obedience/.test(c)) return 'training';
+ if (/park|paw.?park|dog.?park/.test(c)) return 'park';
+ return 'generic';
+}
+
+const COPY_BANKS = {
+ vet: {
+ HEADLINES: [
+ 'Compassionate care, modern medicine.',
+ 'A trusted name in your neighborhood.',
+ 'Where great care begins.',
+ 'Built around your pet, your family, your time.',
+ 'Healthier visits. Honest pricing.',
+ ],
+ SUBS: [
+ 'Caring for {city} families for over a decade.',
+ 'Serving {city} with respect, transparency, and same-day availability.',
+ 'Online booking. Email reminders. Real conversations with real clinicians.',
+ 'Trusted by {city} for everyday care and the occasional emergency.',
+ 'A small, attentive practice in the heart of {city}.',
+ ],
+ EYEBROWS: ['Established practice', 'Now booking', 'Family-owned', 'Trusted in {city}', 'A new chapter'],
+ CTAS: ['Book an appointment', 'Schedule a visit', 'Make an appointment', 'Request a time', 'Get on the schedule'],
+ SERVICES: [
+ [{ t: 'Wellness', p: 'Annual exams, vaccinations, preventive care plans.' },
+ { t: 'Surgery & Dental', p: 'In-house procedures, same-day discharge for most.' },
+ { t: 'Urgent care', p: 'Same-day sick visits — call before going to the ER.' }],
+ [{ t: 'New patient visits', p: 'A real intake conversation, not a clipboard.' },
+ { t: 'Specialty referrals',p: 'Dermatology, cardiology, oncology — we know who.' },
+ { t: 'Senior care', p: 'Quarterly visit + lab panel for patients 8+.' }],
+ [{ t: 'Diagnostics', p: 'On-site lab, digital radiology, ultrasound.' },
+ { t: 'Pharmacy', p: 'Stocked formulary, refills you can pick up same day.' },
+ { t: 'Care plans', p: 'Predictable monthly pricing for routine visits.' }],
+ [{ t: 'Same-day visits', p: "Don't wait a week. Call by 10am, see us by 5pm." },
+ { t: 'Behavior & training',p: 'In-clinic consultations and at-home plans.' },
+ { t: 'House-call program', p: 'Visits at home for senior or anxious patients.' }],
+ ],
+ },
+ shelter: {
+ HEADLINES: [
+ 'Find your new best friend.',
+ 'Every adoption changes two lives.',
+ 'Animals waiting for someone like you.',
+ 'Adopt. Foster. Volunteer. Donate.',
+ 'A second chance starts here.',
+ ],
+ SUBS: [
+ 'Helping {city} pets find homes since day one.',
+ 'Adoptable dogs, cats, and small animals — meet them today.',
+ 'Low-cost spay/neuter, vaccine clinics, and lost-and-found support for {city}.',
+ 'A community shelter, not a private business — every dollar stays local.',
+ 'Open to the public seven days a week in {city}.',
+ ],
+ EYEBROWS: ['Animal shelter', 'Open for adoptions', 'Non-profit', 'Serving {city}', 'Help us help them'],
+ CTAS: ['See adoptable pets', 'Start an adoption', 'Visit the shelter', 'Foster a pet', 'Donate today'],
+ SERVICES: [
+ [{ t: 'Adoptions', p: 'Meet dogs, cats, and small animals available right now.' },
+ { t: 'Lost & found', p: 'Report a missing pet or check our intake list.' },
+ { t: 'Spay / neuter', p: 'Low-cost surgeries for community pets.' }],
+ [{ t: 'Foster program', p: 'Open your home short-term for a pet in transition.' },
+ { t: 'Volunteer', p: 'Walk dogs, socialize cats, or help at events.' },
+ { t: 'Surrender support', p: 'Resources before giving up a pet — and intake when needed.' }],
+ [{ t: 'Vaccine clinics', p: 'Affordable rabies, distemper, and parvo for community pets.' },
+ { t: 'Microchipping', p: 'Walk-in clinics most weekends.' },
+ { t: 'Donate', p: 'Food, supplies, or a one-time gift — every bit reaches the animals.' }],
+ [{ t: 'Education', p: 'School visits, humane-ed workshops, and trap-neuter-return training.' },
+ { t: 'Community cats', p: 'TNR support and barn-cat placement for working colonies.' },
+ { t: 'Memorial gifts', p: 'Honor a pet who has passed with a gift in their name.' }],
+ ],
+ },
+ boarding: {
+ HEADLINES: [
+ 'A home away from home for your pet.',
+ 'Boarding, daycare, and a whole lot of tail wags.',
+ 'Where pets vacation while you do.',
+ 'Clean runs, big yards, kind people.',
+ 'Built for dogs who hate leaving you.',
+ ],
+ SUBS: [
+ 'Family-run boarding in {city} since the start.',
+ 'Climate-controlled suites, outdoor play yards, daily photo updates.',
+ 'Daycare, overnight, long-stay — all on the same trusted property.',
+ 'Hand-fed meals, individual potty breaks, and real outdoor time every day.',
+ 'Trusted by {city} for travel boarding, daycare, and pre-flight stays.',
+ ],
+ EYEBROWS: ['Boarding & daycare', 'Now reserving', 'Family-run', 'Trusted in {city}', 'Take a tour'],
+ CTAS: ['Reserve a stay', 'Check availability', 'Book daycare', 'Schedule a tour', 'Request a reservation'],
+ SERVICES: [
+ [{ t: 'Overnight boarding', p: 'Climate-controlled suites with daily yard time and individual care.' },
+ { t: 'Doggy daycare', p: 'Supervised group play in age- and size-matched groups.' },
+ { t: 'Long-stay rates', p: 'Discounts for travel, deployment, or extended trips.' }],
+ [{ t: 'Cat boarding', p: 'Quiet condos away from the kennel side of the building.' },
+ { t: 'Grooming add-on', p: 'Bath, brush, and nail trim before you pick them up.' },
+ { t: 'Photo updates', p: 'Daily texts so you can see them before bed.' }],
+ [{ t: 'Pickup / dropoff', p: 'Local door-to-door for $20 each way.' },
+ { t: 'Pre-flight stays', p: 'Last-night boarding with morning airport handoff.' },
+ { t: 'Holiday reservations',p:'Thanksgiving, December, and spring break book early.' }],
+ [{ t: 'Required vaccines', p: 'Rabies, DA2PP, and Bordetella — bring records on intake.' },
+ { t: 'Tour the facility', p: 'Walk-throughs welcome any weekday afternoon.' },
+ { t: 'Reservation policy', p: 'Deposit holds your spot. Cancel 48h ahead, no charge.' }],
+ ],
+ },
+ retail: {
+ HEADLINES: [
+ 'Everything your pet needs, all in one place.',
+ 'A neighborhood pet store with real selection.',
+ 'Food, toys, treats — and the people who know them.',
+ 'Local. Independent. Stocked.',
+ 'Quality pet supplies for {city} families.',
+ ],
+ SUBS: [
+ 'Serving {city} with curated food, toys, and supplies.',
+ 'Independent retailer — we know our brands and our customers.',
+ 'In-store pickup, local delivery, and a loyalty program that actually pays back.',
+ 'From premium kibble to the right harness — staff who use what they sell.',
+ 'Stop in and meet the shop dog.',
+ ],
+ EYEBROWS: ['Pet store', 'Open today', 'Locally owned', 'Serving {city}', 'New arrivals weekly'],
+ CTAS: ['Shop in store', 'Browse online', 'Visit the shop', 'See this week\'s deals', 'Find your store'],
+ SERVICES: [
+ [{ t: 'Food & treats', p: 'Premium dry, raw, freeze-dried, and prescription brands.' },
+ { t: 'Toys & enrichment', p: 'Chews, puzzles, and rotating new arrivals.' },
+ { t: 'Beds & crates', p: 'Sized for the apartment, the SUV, and the back porch.' }],
+ [{ t: 'Leashes & collars', p: 'Walking gear, no-pull harnesses, training tools.' },
+ { t: 'Health & supplements',p:'Joint, skin, allergy, and digestion aisles.' },
+ { t: 'Small pets', p: 'Habitats, bedding, and food for small animals and birds.' }],
+ [{ t: 'Local delivery', p: 'Same-day delivery within 5 miles of the store.' },
+ { t: 'Auto-ship', p: 'Recurring orders with a 5% loyalty discount.' },
+ { t: 'Special order', p: 'Anything we don\'t stock — usually in by Friday.' }],
+ [{ t: 'Loyalty program', p: '10% back on every $200 spent. No card, just your phone.' },
+ { t: 'Weekend events', p: 'Adoption days, vendor demos, and birthday parties.' },
+ { t: 'Gift cards', p: 'Available in store and online — never expire.' }],
+ ],
+ },
+ grooming: {
+ HEADLINES: [
+ 'A clean dog is a happy dog.',
+ 'Grooming with patience for nervous pets.',
+ 'Spa day for the four-legged crew.',
+ 'From puppy cuts to show grooms.',
+ 'Where every dog leaves looking like the best version of themselves.',
+ ],
+ SUBS: [
+ 'Family-run grooming in {city}.',
+ 'Bath, brush, hand-strip — booked one pet at a time, never overlapped.',
+ 'Online booking, photo updates, and pickup in under three hours.',
+ 'Cat grooming on Tuesdays — quiet day, no dogs in the building.',
+ 'Mobile grooming option for senior or anxious pets.',
+ ],
+ EYEBROWS: ['Pet grooming', 'Booking now', 'Family-run', 'Serving {city}', 'See our work'],
+ CTAS: ['Book a groom', 'Schedule a bath', 'Reserve a slot', 'Request a time', 'See pricing'],
+ SERVICES: [
+ [{ t: 'Full groom', p: 'Bath, blow-dry, haircut, ears, nails, and finish spray.' },
+ { t: 'Bath & brush', p: 'Bath, blow-out, and a tidy of the face and feet.' },
+ { t: 'Nail trim', p: 'Walk-in nails most days — under five minutes.' }],
+ [{ t: 'Cat grooming', p: 'Tuesdays only, by appointment, no dogs in the building.' },
+ { t: 'De-shedding', p: 'Furminator + bath package for heavy coat blowouts.' },
+ { t: 'Senior gentle', p: 'Slower pace, soft restraint, breaks as needed.' }],
+ [{ t: 'Hand-stripping', p: 'Terriers and wire-coats, done the right way.' },
+ { t: 'Show grooming', p: 'Breed-standard cuts for AKC and UKC entries.' },
+ { t: 'Add-ons', p: 'Teeth brushing, paw balm, blueberry facial.' }],
+ [{ t: 'Mobile grooming', p: 'On-site at your house for senior or anxious pets.' },
+ { t: 'Puppy first groom', p: 'Gentle intro session under 30 minutes.' },
+ { t: 'Gift cards', p: 'Available for any service or dollar amount.' }],
+ ],
+ },
+ training: {
+ HEADLINES: [
+ 'Better walks, calmer dogs, happier homes.',
+ 'Force-free training that actually sticks.',
+ 'From puppy basics to working obedience.',
+ 'Trained dogs, trained owners.',
+ 'Real behavior change — not bribes.',
+ ],
+ SUBS: [
+ 'Group classes and private sessions in {city}.',
+ 'Certified trainers using positive-reinforcement methods.',
+ 'Small classes, big results, lifetime support after graduation.',
+ 'Behavior consults for reactivity, anxiety, and resource guarding.',
+ 'Trusted by {city} families and rescues.',
+ ],
+ EYEBROWS: ['Dog training', 'Now enrolling', 'Certified trainers', 'Serving {city}', 'Force-free'],
+ CTAS: ['Enroll in a class', 'Book a session', 'See class schedule', 'Request a consult', 'Get started'],
+ SERVICES: [
+ [{ t: 'Puppy class', p: 'Socialization, handling, and the first ten essential cues.' },
+ { t: 'Basic manners', p: 'Sit, down, stay, recall, loose-leash walking.' },
+ { t: 'Advanced obedience', p: 'Off-leash control, distance work, distraction proofing.' }],
+ [{ t: 'Private sessions', p: 'In-home or facility, tailored to your dog\'s plan.' },
+ { t: 'Reactivity', p: 'Leash reactivity to dogs, people, or both.' },
+ { t: 'Anxiety & fear', p: 'Confidence-building protocols with vet partnership.' }],
+ [{ t: 'Board & train', p: 'Two- and four-week intensive programs.' },
+ { t: 'Day school', p: 'Drop-off training while you work.' },
+ { t: 'Group walks', p: 'Pack outings in supervised, structured groups.' }],
+ [{ t: 'Sport intro', p: 'Agility, scent work, and rally fundamentals.' },
+ { t: 'Therapy prep', p: 'Canine Good Citizen and therapy-team readiness.' },
+ { t: 'Lifetime support', p: 'Free refresher class after every grad program.' }],
+ ],
+ },
+ park: {
+ HEADLINES: [
+ 'A great place for dogs to be dogs.',
+ 'Off-leash fun, on-leash safety.',
+ 'Where {city} dogs make friends.',
+ 'Bring water. Bring a ball. Stay a while.',
+ 'A community park for our four-legged neighbors.',
+ ],
+ SUBS: [
+ 'Open dawn to dusk in {city}.',
+ 'Separate areas for small and large dogs, with shaded benches and water.',
+ 'Free to use — vaccines and tags required.',
+ 'Maintained by volunteers and {city} parks staff.',
+ 'Bring poop bags, bring patience, leave it cleaner than you found it.',
+ ],
+ EYEBROWS: ['Dog park', 'Open daily', 'Volunteer-supported', 'In {city}', 'Park rules'],
+ CTAS: ['See park rules', 'Volunteer', 'Donate', 'Find the park', 'Get involved'],
+ SERVICES: [
+ [{ t: 'Small dog area', p: 'Fenced area for dogs under 25 lbs.' },
+ { t: 'Large dog area', p: 'Open run for dogs over 25 lbs.' },
+ { t: 'Water stations', p: 'Year-round potable water and rinse-off station.' }],
+ [{ t: 'Hours', p: 'Open dawn to dusk, every day of the year.' },
+ { t: 'Park rules', p: 'Vaccines + tags, no food, fill in any holes you dig.' },
+ { t: 'Pickup stations', p: 'Free poop bags at every entry.' }],
+ [{ t: 'Volunteer', p: 'Mowing, raking, and Saturday clean-up days.' },
+ { t: 'Donate', p: 'Bench dedications and bag-station sponsorships.' },
+ { t: 'Events', p: 'Yappy hours, breed meet-ups, and adoption events.' }],
+ [{ t: 'Lost & found', p: 'Check the bulletin board or the park\'s Facebook group.' },
+ { t: 'Etiquette', p: 'Leashed entry, supervise your dog, intervene early.' },
+ { t: 'Friends of the park',p: 'Join the volunteer group on Facebook.' }],
+ ],
+ },
+ generic: {
+ HEADLINES: [
+ 'Built around your pet.',
+ 'A trusted name in {city}.',
+ 'Where great care begins.',
+ 'Family-run, locally trusted.',
+ 'Honest service. Real people.',
+ ],
+ SUBS: [
+ 'Serving {city} families with the kind of care we\'d want for our own.',
+ 'Locally owned and proud of it.',
+ 'Online booking. Email reminders. Real conversations.',
+ 'A small, attentive business in the heart of {city}.',
+ 'Trusted by {city} for the everyday and the occasional.',
+ ],
+ EYEBROWS: ['Locally owned', 'Now booking', 'Family-run', 'Trusted in {city}', 'Get in touch'],
+ CTAS: ['Get in touch', 'Schedule a visit', 'Request a time', 'Contact us', 'Learn more'],
+ SERVICES: [
+ [{ t: 'Our services', p: 'A full menu — call us if you don\'t see what you need.' },
+ { t: 'About us', p: 'Family-run, here for the long haul.' },
+ { t: 'Visit us', p: 'Open most weekdays — see hours below.' }],
+ [{ t: 'New customers', p: 'Welcome — we\'ll take time to get to know you.' },
+ { t: 'Loyalty', p: 'Discounts and perks for returning families.' },
+ { t: 'Gift cards', p: 'Available in any amount, never expire.' }],
+ [{ t: 'Service area', p: `Proudly serving ${''} and surrounding communities.` },
+ { t: 'Hours & location', p: 'See the footer for address and phone.' },
+ { t: 'Contact', p: 'Call, text, or email — we get back same-day.' }],
+ [{ t: 'Reviews', p: 'See what our customers say on Google and Yelp.' },
+ { t: 'Community', p: 'Sponsorships, donations, and local events.' },
+ { t: 'Stay in touch', p: 'Newsletter sign-up at the bottom of the page.' }],
+ ],
+ },
+};
// String-template helpers used by every layout — defined here (above LAYOUTS)
// because arrow functions assigned to `const` don't hoist.
@@ -244,8 +483,9 @@ for (const biz of targets) {
const layout = LAYOUTS[picks[i]];
const palette = PALETTES[(seed * 17 + i * 5) % PALETTES.length];
const style = STYLES[ (seed * 23 + i * 11) % STYLES.length];
- const copy = pickCopy(seed, i);
- const html = applyStyle(layout.fn(biz, palette, copy, style), style);
+ const copy = pickCopy(seed, i, biz);
+ const rawHtml = applyStyle(layout.fn(biz, palette, copy, style), style);
+ const html = enhance(rawHtml, biz, shared(biz));
const htmlPath = path.join(MOCK_DIR, `biz-${biz.id}-${v}.html`);
fs.writeFileSync(htmlPath, html);
const shotPath = path.join(MOCK_DIR, `biz-${biz.id}-${v}.png`);
@@ -283,14 +523,82 @@ function pickThreeLayouts(seed) {
while (c === a || c === b) c = (c + 1) % N;
return [a, b, c];
}
-function pickCopy(seed, slot) {
+function pickCopy(seed, slot, biz) {
+ // [morning-review 2026-05-04] CRITICAL bug fix: prior version referenced
+ // top-level HEADLINES/SUBS/EYEBROWS/CTAS/SERVICE_SETS that no longer exist
+ // (replaced by COPY_BANKS keyed by category). Generator was throwing
+ // ReferenceError on every business — also why kennel mockups still showed
+ // vet copy: the catch upstream was silently using the cached old templates.
+ const bank = COPY_BANKS[categoryBucket(biz)] || COPY_BANKS.generic;
return {
- headline: HEADLINES[(seed + slot * 3) % HEADLINES.length],
- sub: SUBS[(seed * 2 + slot) % SUBS.length],
- services: SERVICE_SETS[(seed * 3 + slot * 2) % SERVICE_SETS.length],
- eyebrow: EYEBROWS[(seed + slot * 7) % EYEBROWS.length],
- cta: CTAS[(seed * 5 + slot) % CTAS.length],
+ headline: bank.HEADLINES[(seed + slot * 3) % bank.HEADLINES.length],
+ sub: bank.SUBS[(seed * 2 + slot) % bank.SUBS.length],
+ services: bank.SERVICES[(seed * 3 + slot * 2) % bank.SERVICES.length],
+ eyebrow: bank.EYEBROWS[(seed + slot * 7) % bank.EYEBROWS.length],
+ cta: bank.CTAS[(seed * 5 + slot) % bank.CTAS.length],
+ };
+}
+
+// [morning-review 2026-05-04] Per-template HEAD/anchor patches applied AFTER
+// the layout fn returns so we don't have to edit 25 layout templates inline.
+// * lang="en", viewport, <title>, description meta — fixes mobile zoom-out,
+// screen reader silence, blank tab titles, broken share/SEO surfaces.
+// * <small> eyebrow → <span> — <small> per HTML spec is fine print; using
+// it for an eyebrow corrupts AT semantics + heading order.
+// * href="#book" / "#tour" / "#about" → tel: when phone is available.
+// Prior code emitted dead anchors that did nothing on click.
+function enhance(html, biz, s) {
+ const tel = String(biz?.phone || '').replace(/[^\d+]/g, '');
+ const cta = tel ? `tel:${tel}` : `mailto:${biz?.email || ''}`;
+ const desc = `${s.name} — ${s.cat} in ${esc(biz?.city || '')}${biz?.state ? ', ' + esc(biz.state) : ''}. ${s.phone}.`;
+ const titleText = `${s.name} — ${esc(biz?.city || s.cat)}`;
+ // LocalBusiness JSON-LD: tiny SEO win the inline-template layouts otherwise
+ // skip. Only emit address fields that exist so we don't ship empty strings
+ // to Google. Escape `<` so the JSON literal can't smuggle a closing
+ // </script>.
+ const ld = {
+ '@context': 'https://schema.org',
+ '@type': 'LocalBusiness',
+ name: biz?.name || s.name,
+ ...(biz?.phone ? { telephone: biz.phone } : {}),
+ ...(biz?.website ? { url: biz.website } : {}),
+ ...((biz?.address || biz?.city || biz?.state) ? {
+ address: {
+ '@type': 'PostalAddress',
+ ...(biz?.address ? { streetAddress: biz.address } : {}),
+ ...(biz?.city ? { addressLocality: biz.city } : {}),
+ ...(biz?.state ? { addressRegion: biz.state } : {}),
+ ...(biz?.zip ? { postalCode: biz.zip } : {}),
+ addressCountry: 'US',
+ },
+ } : {}),
};
+ const ldJson = JSON.stringify(ld).replace(/</g, '\\u003c');
+ const metas = `<meta name=viewport content="width=device-width,initial-scale=1"><title>${titleText}</title><meta name=description content="${desc.replace(/"/g, '"')}"><script type="application/ld+json">${ldJson}</script>`;
+ let out = html
+ .replace(/<html(?![^>]*\blang=)/i, '<html lang="en"')
+ .replace(/<meta charset=utf-8>/i, `<meta charset=utf-8>${metas}`)
+ .replace(/<small\b/g, '<span')
+ .replace(/<\/small>/g, '</span>');
+ // Dead-anchor sweep — skip when there's nowhere useful to send the click.
+ // Includes #refer (vet variant) which the prior pass missed.
+ if (tel || biz?.email) {
+ out = out.replace(/href="#(book|about|tour|contact|refer)"/g, `href="${cta}"`);
+ }
+ // Tap-to-call: wrap any rendered phone number in <a href="tel:…"> so mobile
+ // taps actually dial. Lead-char guard (>, whitespace, ·) keeps us from
+ // re-wrapping numbers already inside href attrs from the previous step.
+ if (tel) {
+ out = out.replace(
+ /(>|\s|·)\s*(\+?1[-\s.]?)?\(?(\d{3})\)?[-\s.]?(\d{3})[-\s.]?(\d{4})(?=[\s<·]|$)/g,
+ (_m, lead, cc, a, b, c) => {
+ const dial = `+1${a}${b}${c}`;
+ const human = `${cc ? cc.trim() : '+1'}-${a}-${b}-${c}`;
+ return `${lead}<a href="tel:${dial}" style="color:inherit;text-decoration:none">${human}</a>`;
+ }
+ );
+ }
+ return out;
}
// ─── shared helpers ───────────────────────────────────────────────────────
@@ -724,7 +1032,7 @@ body{font-family:'Inter',sans-serif;background:#fff;color:${p.ink};margin:0}
.row p{margin:0;color:${p.muted};font-size:.95em;line-height:1.55}
.foot{padding:2em 4em;background:${p.dark};color:#fff;font-size:.9em;opacity:.85}
</style></head><body>
-<header class=bar><strong>${s.name}</strong><nav><a>Services</a><a>Team</a><a>Visit</a></nav></header>
+<header class=bar><strong>${s.name}</strong><nav><a href="#services">Services</a><a href="#team">Team</a><a href="#visit">Visit</a></nav></header>
<section class=hero>
<div class=lhs><small>${esc(eb(copy, biz.city))}</small><div class=num>${String(biz.id || 1).padStart(2,'0')}</div></div>
<div class=rhs><h1>${esc(copy.headline)}</h1><p>${esc(sub(s, copy, biz.city))}</p><a class=cta href="#book">${esc(copy.cta)}</a></div>
@@ -869,13 +1177,13 @@ body{font-family:'Inter',sans-serif;background:${p.bg};color:${p.ink};margin:0}
<p>${esc(copy.headline)} ${esc(sub(s, copy, biz.city))}</p>
</div>
<div class=right>
- <div class=form>
+ <form class=form action="#book" method="post">
<h3>${esc(copy.cta)}</h3>
- <label>Your name</label><input type=text placeholder="Jane Smith">
- <label>Email</label><input type=email placeholder="you@example.com">
- <label>Reason for visit</label><select><option>${esc(copy.services[0].t)}</option><option>${esc(copy.services[1].t)}</option><option>${esc(copy.services[2].t)}</option></select>
- <button>Request appointment</button>
- </div>
+ <label for=appt-name>Your name</label><input id=appt-name name=name type=text placeholder="Jane Smith" required>
+ <label for=appt-email>Email</label><input id=appt-email name=email type=email placeholder="you@example.com" required>
+ <label for=appt-reason>Reason for visit</label><select id=appt-reason name=reason><option>${esc(copy.services[0].t)}</option><option>${esc(copy.services[1].t)}</option><option>${esc(copy.services[2].t)}</option></select>
+ <button type=submit>Request appointment</button>
+ </form>
</div>
</section>
<section class=three>${copy.services.map(x=>`<div><strong>${esc(x.t)}</strong><p>${esc(x.p)}</p></div>`).join('')}</section>
@@ -968,7 +1276,7 @@ body{font-family:'Inter',sans-serif;background:${p.surface};color:${p.ink};margi
.tile p{margin:0;color:${p.muted};font-size:.95em;line-height:1.55}
.foot{padding:2em;background:${p.dark};color:#fff;text-align:center;opacity:.85;font-size:.9em}
</style></head><body>
-<header class=nav><strong>${s.name}</strong><a>Care</a><a>Team</a><a>Visit</a><a class=cta-mini href="#book">${esc(copy.cta)}</a></header>
+<header class=nav><strong>${s.name}</strong><a href="#care">Care</a><a href="#team">Team</a><a href="#visit">Visit</a><a class=cta-mini href="#book">${esc(copy.cta)}</a></header>
<section class=hero>
<div>
<h1>${esc(copy.headline)}</h1>
diff --git a/src/scripts/build_subsites.js b/src/scripts/build_subsites.js
index 3d48a92..1488f87 100644
--- a/src/scripts/build_subsites.js
+++ b/src/scripts/build_subsites.js
@@ -62,6 +62,20 @@ let ok = 0, err = 0;
try {
for (const biz of targets) {
try {
+ // [morning-review 2026-05-04] HIGH fix: previously a missing/empty
+ // biz.city resolved to {city} → biz.state in the address line, e.g.
+ // "1000 East Central Road, IL, 60005 · …" with the city dropped.
+ // The slug `march-animal-hospital-il-il-5722` shows the same template
+ // var collapsing to the state. Skip the build for these rows so the
+ // human review queue can fix the source data instead of us shipping
+ // a placeholder.
+ const cityNorm = String(biz.city || '').trim().toLowerCase();
+ const stateNorm = String(biz.state || '').trim().toLowerCase();
+ if (!cityNorm || cityNorm === stateNorm) {
+ err++;
+ log.warn(`× ${biz.name}: skipped — missing/duplicated city/state (city=${biz.city || '∅'}, state=${biz.state || '∅'})`);
+ continue;
+ }
const slug = makeSlug(biz);
const dir = path.join(SITES_DIR, slug);
fs.mkdirSync(dir, { recursive: true });
@@ -106,23 +120,24 @@ function makeSlug(biz) {
}
function pageHtml(page, biz, spec, slug) {
- // Use the design_system layout for the index page (already includes
- // hero+services+footer). Other pages reuse the layout with a different
- // payload-bearing page intro section.
- let baseHtml = renderHtml(spec, biz);
-
+ // Index uses the full design_system layout (hero + specs + footer).
+ const baseHtml = renderHtml(spec, biz);
if (page === 'index') return baseHtml;
- // Subpages need unique <title> so SERPs don't see them as soft-duplicates.
+ // Subpages reuse the index's <head> (palette + fonts + CSS variables) but
+ // render their own slim body — header/nav, single <main>/<h1>, footer.
+ // Prior implementation prepended the entire homepage and appended the
+ // subpage <section> *after* </footer>, producing dual <h1>, footer-mid-
+ // document, and ~1MB of duplicate content per site.
const labels = { services: 'Services', team: 'Our Team', contact: 'Contact' };
const escName = esc(biz.name || 'Your Business');
const pageLabel = labels[page] || page;
- baseHtml = baseHtml.replace(
+
+ let html = baseHtml.replace(
/<title>[^<]*<\/title>/,
`<title>${escName} — ${esc(pageLabel)}</title>`
);
- // Pre-escape all biz fields before interpolating into HTML.
const safe = {
name: escName,
phone: esc(biz.phone || ''),
@@ -135,28 +150,43 @@ function pageHtml(page, biz, spec, slug) {
const tel = telHref(biz.phone);
const websiteUrl = safeWebsiteHref(biz.website);
- // For services / team / contact: inject a page header into the same shell.
- // Simplest: append a page-specific section before </body>.
- const heads = {
- services: `<section style="padding:4em 3em;max-width:900px;margin:0 auto"><h1>Our Services</h1>
- <ul style="line-height:2;font-size:1.1em">
+ const sections = {
+ services: `<ul style="line-height:2;font-size:1.1em">
<li>Annual wellness exams + vaccinations</li>
<li>Same-day sick visits (M-F before 11 AM)</li>
<li>In-house surgical suite with continuous anesthetic monitoring</li>
<li>Dental cleanings + extractions</li>
<li>Specialty referrals (cardiology, dermatology, oncology)</li>
<li>End-of-life consultations + in-home euthanasia</li>
- </ul></section>`,
- team: `<section style="padding:4em 3em;max-width:900px;margin:0 auto"><h1>Our Team</h1>
- <p style="line-height:1.7;font-size:1.1em">Board-certified veterinarians, Fear Free Certified® technicians, and front-desk staff who remember your pet's name. Photos + bios coming soon — call us at ${safe.phone} for an introduction.</p></section>`,
- contact: `<section style="padding:4em 3em;max-width:900px;margin:0 auto"><h1>Contact</h1>
- <p style="line-height:1.7;font-size:1.1em"><strong>${safe.name}</strong><br>
- ${safe.addrLine}<br>
- ${tel ? `<a href="tel:${esc(tel)}">${safe.phone}</a><br>` : ''}
- ${biz.email ? `<a href="mailto:${safe.email}">${safe.email}</a><br>` : ''}
- ${websiteUrl ? `<a href="${esc(websiteUrl)}" target="_blank" rel="noopener noreferrer">${esc(websiteUrl)}</a><br>` : ''}
- </p></section>`,
+ </ul>`,
+ team: `<p style="line-height:1.7;font-size:1.1em">Board-certified veterinarians, Fear Free Certified® technicians, and front-desk staff who remember your pet's name. Photos + bios coming soon${tel ? ` — call us at <a href="tel:${esc(tel)}">${safe.phone}</a> for an introduction` : ''}.</p>`,
+ contact: `<p style="line-height:1.7;font-size:1.1em"><strong>${safe.name}</strong>${safe.addrLine ? `<br>${safe.addrLine}` : ''}
+ ${tel ? `<br><a href="tel:${esc(tel)}">${safe.phone}</a>` : ''}
+ ${biz.email ? `<br><a href="mailto:${safe.email}">${safe.email}</a>` : ''}
+ ${websiteUrl ? `<br><a href="${esc(websiteUrl)}" target="_blank" rel="noopener noreferrer">${esc(websiteUrl)}</a>` : ''}
+ </p>`,
};
- // Inject the section right before </body>
- return baseHtml.replace('</body>', `${heads[page] || ''}</body>`);
+
+ const navLink = (href, key, label) =>
+ `<a href="${href}" style="color:var(--ink);text-decoration:none${page === key ? ';font-weight:700;border-bottom:2px solid var(--accent)' : ''}">${label}</a>`;
+
+ const footerBits = [safe.name, safe.addrLine, safe.phone].filter(Boolean).join(' · ');
+ const subpageBody = `
+ <header style="padding:1.4em 2em;border-bottom:1px solid var(--rule);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1em">
+ <p class="brand" style="margin:0;font-family:var(--h-font);font-size:1.2em;font-weight:700"><a href="./" style="color:var(--ink);text-decoration:none">${safe.name}</a></p>
+ <nav style="display:flex;gap:1.4em;font-size:.95em">
+ ${navLink('./', 'index', 'Home')}
+ ${navLink('services.html', 'services', 'Services')}
+ ${navLink('team.html', 'team', 'Team')}
+ ${navLink('contact.html', 'contact', 'Contact')}
+ </nav>
+ </header>
+ <main style="padding:4em 3em;max-width:900px;margin:0 auto">
+ <h1>${esc(pageLabel)}</h1>
+ ${sections[page] || ''}
+ </main>
+ <footer style="padding:2em 3em;color:var(--muted);text-align:center;border-top:1px solid var(--rule);font-size:.9em">${footerBits}</footer>`;
+
+ // Replace the full body — the homepage's hero/specs/footer must not leak in.
+ return html.replace(/<body>[\s\S]*<\/body>/, `<body>${subpageBody}</body>`);
}
← b9336a0 [morning-review] animals: escape biz fields, validate websit
·
back to Animals
·
checkpoint: codex --apply overnight + gitignore generated co 01839be →