SLOPSHOPPER

zookeeper

Flags BeastlyFacts house-rule slips in edits, shell calls and chat as they happen; blocks npm run build unless asked.

newbandguardprompt
v0.1.0no licenseupdated 2026-10-05mrd1030/beastlyfactss/.claude/mods/zookeeper
A shopper browsing a rack in a slop shop
README

BeastlyFacts

BeastlyFacts is a content site with honest, practical, well sourced information about pets and animals, weighted toward reptiles, amphibians, small mammals, and invertebrates. It ships as a prerendered React app on Cloudflare, with every article stored as MDX in this repo.

New session here? Read READMEFIRST.md for current state and what to work on, CLAUDE.md for the working rules, and docs/RULES.md before writing or editing any content. This file is the map of the codebase; those three win over it.

Tech stack

  • Frontend: Vite 6 + React 18, React Router, framer-motion
  • Styling: Tailwind CSS + shadcn/ui components
  • Content: MDX files in content/, compiled by @mdx-js/rollup
  • Data: plain JS modules in src/lib/data/ (facts, encyclopedia, Beastlypedia, guides, affiliate products, legal status)
  • Backend: Supabase for auth, favorites, push notifications, and the social feed queue; Stripe for care package checkout
  • Hosting: Cloudflare, with public/_worker.js serving the API routes, the RSS feeds, and the care package checkout and download endpoints
  • Prerendering: prerender.mjs plus scripts/generate-fact-pages.mjs, so every article and fact page is static HTML before hydration

Project structure

├── content/                  # every article, as MDX
│   ├── guides/               # species guides (cost / handling / health / tank setup / feeding / enrichment)
│   ├── fun-facts/            # numbered list articles
│   ├── blog/                 # standalone posts
│   ├── short-story/          # the Chronicles
│   └── CAREPACKAGE Guides/   # printable care package sources and PDFs
├── src/
│   ├── components/
│   │   ├── mdx/              # components usable inside article bodies
│   │   ├── blog/             # article page furniture (Deep Dive, TOC, FAQ)
│   │   ├── beastlypedia/ encyclopedia/ guides/ home/ pack/ legal/
│   │   ├── layout/ shared/ ui/
│   ├── pages/                # route components
│   ├── lib/
│   │   ├── data/             # the content data layer
│   │   ├── generated/        # build output, do not hand edit
│   │   └── seo/ hooks/ utils/
│   └── api/supabaseClient.js
├── scripts/                  # content checks and index generators
├── docs/                     # rulebook, plans, runbooks, logs
├── supabase/                 # SQL schema and edge functions
├── templates/                # MDX starter templates
├── tools/                    # image audit, keyword finder, legal research
└── public/                   # static assets and _worker.js

Getting started

npm install
npm run dev

dev regenerates thumbnails and the article, guide, encyclopedia, Beastlypedia, and legal indexes before starting Vite, so a new MDX file shows up without extra steps.

Supabase is optional locally. Without VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY, auth and favorites degrade gracefully and everything else runs.

Content checks

npm run build runs the full gauntlet before Vite ever starts, so a content mistake fails the build instead of shipping. Each check also runs on its own:

CommandWhat it enforces
npm run check:linksevery article carries 1 to 2+ in-body internal links, to valid targets
npm run check:voicethe voice rules in docs/RULES.md, against scripts/voice-baseline.json
npm run check:costscost guides cover the required line items
npm run check:affiliateaffiliate articles pair affiliate: true with the disclosure
npm run check:relatednew articles are wired into relatedArticles.js, no dead slugs
npm run check:seoseoTitle and seoDescription length and presence
npm run check:imagesevery referenced image exists and has dimensions
npm run check:sourcessource URLs in Sources blocks still resolve
npm run check:numbersspecies numbers stay consistent across guides
npm run check:encyclopediaencyclopedia entries match their guides
npm run check:legal-syncthe legal map matches legalStatus.json
npm run check:link-depthinternal link depth across the whole site

npm run lint and npm run typecheck cover the code side.

Content and infographic work stops at the image and internal-link checks. Do not run npm run build unless you have a reason to.

Writing content

Start from templates/GUIDE_TEMPLATE.mdx or templates/FUN_FACT_TEMPLATE.mdx and read templates/README.md for the frontmatter schema. The frontmatter is the structured data: BlogPosting, BreadcrumbList, and FAQPage JSON-LD are all generated from it, so never hand write a schema block in an article.

The non-negotiables, in full in docs/RULES.md:

  • No em or en dashes, anywhere.
  • US spelling in prose, "grey" preferred.
  • Species guides keep their split structure (cost / handling / health / tank setup). They do not become long form pillar pages.
  • Never renumber facts. Removing one leaves an id gap, because favorites are keyed by id.
  • Never share one photo across multiple facts.
  • Every article needs 1 to 2+ in-body internal links, written by hand, never to the same species' sibling guides or its care guide hub.
  • Date an article the day it ships, never ahead; ship a batch 4 to 5 per day at most. Articles written ahead wait in content/_scheduled-*.
  • Dates use US Eastern, not the container clock.

MDX components

Located in src/components/mdx/:

  • <Figure /> images with captions and alt text
  • <FunFact /> highlighted callouts
  • <KeyTakeaway /> the one thing a reader should leave with
  • <ProsCons /> pros and cons blocks
  • <ComparisonTable /> comparison tables
  • <AffiliateLink /> and <AffiliateDisclosure /> affiliate links and the disclosure that has to accompany them
  • <Sources /> and <AlsoConsulted /> references
  • <VetDisclaimer /> and <LegalDisclaimer /> for medical and legal content
  • <CarePackageBlock /> care package promo block
  • <MdxLink /> and <DemotedH1 /> internal link and heading handling

Care packages

The printable PDF packages sold through Stripe. Sources live in content/CAREPACKAGE Guides/source/*.html, one self contained HTML file per package rendered to PDF with headless Chrome. The older care-packages/ folder is superseded; see its README before touching it. Checkout, the Stripe webhook, and signed downloads are handled in public/_worker.js.

Goals

  • High quality, trustworthy pet care information, researched from real sources
  • Excellent SEO and reading experience, with static HTML for every page
  • Transparent affiliate monetization and a small paid product line

Built with care for animals and the people who love them.

Source 2 files
hooks/register.tsx 267 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Flag } from '../types'
5
6const flags = atom({ plugin: 'zookeeper', key: 'flags' } as const, [])
7
8const MAX_FLAGS = 20
9const SHOWN = 4
10
11// Pattern checks only. Each returns zero or more { rule, detail } hits for a
12// piece of text that was just added (not text the edit merely kept).
13
14type Hit = { rule: string; detail: string }
15
16const snip = (text: string, index: number, length: number) =>
17  text
18    .slice(Math.max(0, index - 25), index + length + 25)
19    .replace(/\s+/g, ' ')
20    .trim()
21
22const norm = (path: string) => path.replace(/\\/g, '/')
23const short = (path: string) => norm(path).split('/').slice(-2).join('/')
24
25const isProse = (path: string) =>
26  /\.mdx?$/i.test(path) || /\/src\/lib\/data\//.test(norm(path))
27const isContent = (path: string) => /\/content\/.*\.mdx?$/i.test(norm(path))
28
29// Lines a legal statute quote or SEO tag lives on are exempt.
30const STATUTE = /§|^\s*>|\b(U\.S\.C|C\.F\.R|Stat|Rev|Admin|Ann|Code|Regs?)\b\.?\s*(§|\d|ch\.|tit)/i
31const SEO_LINE = /^\s*["']?(keywords|seo\w*|meta\w*|tags|ogTitle|ogDescription)["']?\s*[:=]/i
32
33const BRITISH: [RegExp, string][] = [
34  [/\b(colour|behaviour|favourite|flavour|harbour|honour|humour|labour|neighbour|odour|rumour|savour|vapour|armour|endeavour|parlour|vigour|splendour)(s|ed|ing|ite|ites|al|ally)?\b/gi, '-our'],
35  [/\b(centre|fibre|litre|metre|theatre|calibre|sabre|spectre|lustre|meagre|sombre)s?\b/gi, '-re'],
36  [/\b(organis|recognis|realis|apologis|minimis|maximis|optimis|prioritis|socialis|specialis|categoris|customis|emphasis|stabilis|sterilis|immobilis|summaris|utilis|visualis|characteris|criticis|memoris|sanitis|neutralis|fertilis)(e|es|ed|ing|ation|ations|er|ers)\b/gi, '-ise'],
37  [/\b(analys|paralys|catalys)(e|es|ed|ing)\b/gi, '-yse'],
38  [/\b(aluminium|catalogue|programme|jewellery|mould|moult|moulting|plough|sceptical|tyre|kerb|pyjamas|enrol|fulfil|licence|defence|offence|pretence|grey|greys|greyish|travelled|travelling|traveller|modelled|modelling|labelled|labelling|cancelled|cancelling|levelled|fuelled|signalled|counselling|marvellous|oesophag\w*|haemo\w*|anaesthe\w*|oestr\w*|paediatric\w*|faeces|diarrhoea|foetus|leukaemia|anaemi\w*|ischaemi\w*|orthopaedic\w*|whilst|amongst)\b/gi, 'British form'],
39]
40// "grey" survives in names spelled that way.
41const GREY_OK = /\b(african|timneh|congo)\s+greys?\b|\bgrey\s+(kangaroo|parrot)s?\b/gi
42
43const ADS = /\b(shop now|buy now|order now|sign up (today|now)|use (promo |discount )?code|promo code|discount code|\d+% off|free shipping|best (deals|prices)|unbeatable|visit (their|the) (store|shop|site|website)|head (over )?to (their|the) (store|shop|site|website)|available (at|on|from) (amazon|chewy|petco|petsmart|walmart|target|ebay)|our (favorite|top) (pick|brand|retailer)|trusted (brand|retailer|seller)|industry[- ]leading|world'?s (best|leading)|highly recommend (them|their)|go-to (shop|store|brand|retailer))\b|[?&]utm_\w+=|[?&](ref|aff|affiliate)=/gi
44
45const SEE_OUR = /\b(see|check out|read|visit) our\b/gi
46const CLOSER = /^\s*(for more|related (reading|guides|articles)|further reading|keep reading|more (on|from|guides)|learn more|read next|you might also|see also|explore more)\b/i
47
48const UTC_TIME = /\b(?:[01]?\d|2[0-3]):[0-5]\d(?::[0-5]\d)?\s*(?:[ap]\.?m\.?\s*)?(?:UTC|GMT|Z)\b|\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?Z\b/gi
49const ZONE_LABEL = /\b(?:[01]?\d|2[0-3]):[0-5]\d\s*(?:[ap]\.?m\.?\s*)?(?:EDT|EST|ET|Eastern)\b/gi
50
51const CYCLING = /\b(CLAUDE|NEEDS_IMAGE|IMAGE_PROMPTS|BEASTLYPEDIA_FACT_GAPS)\.md\b/
52const MOVE = /\b(git\s+mv|mv|move|Move-Item|Rename-Item|cp|copy|Copy-Item)\b/i
53const CROP = /(\bcrop\b|\.extract\(|-crop\b|-extent\b|fit:\s*['"]?(cover|fill)\b|\bfit=(cover|fill)\b|\.resize\(\s*\d+\s*,\s*\d+)/i
54const FACT_PHOTO = /(assets[\/\\]+facts|FACT_IMAGES|fact[ _-]?(photo|image)s?)/i
55
56// Only a build that runs: npm at a command position, after heredoc bodies
57// and quoted strings (commit messages, echo text) are cut away.
58const BUILD = /(?:^|[;&|(\n])\s*npm\s+run\s+build(?=$|[\s;&|)])/m
59const runnable = (command: string) =>
60  command
61    .replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2\s*$/gm, '')
62    .replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, '""')
63const ASKED_BUILD = /\b(npm run build|run (the |a )?(full )?build|do (the |a )?(full )?build|build (it|the site|now)|go ahead (and )?build|please build|ok(ay)? to build|you can build)\b/gi
64const NEGATED = /\b(don'?t|do not|never|no|not|without|unless|blocks?|blocking|skip)\b[^.]{0,25}$/i
65
66function scanProse(path: string, added: string): Hit[] {
67  const hits: Hit[] = []
68  const lines = added.split('\n')
69
70  for (const line of lines) {
71    if (STATUTE.test(line) || SEO_LINE.test(line)) continue
72
73    const dash = line.search(/[–—]/)
74    if (dash >= 0) hits.push({ rule: 'dash', detail: snip(line, dash, 1) })
75
76    const kept = line.replace(GREY_OK, m => '_'.repeat(m.length))
77    for (const [re, kind] of BRITISH) {
78      for (const m of kept.matchAll(re)) {
79        hits.push({ rule: 'British spelling', detail: `"${m[0]}" (${kind})` })
80      }
81    }
82
83    for (const m of line.matchAll(ADS)) {
84      hits.push({ rule: 'reads like an ad', detail: snip(line, m.index ?? 0, m[0].length) })
85    }
86  }
87
88  if (isContent(path)) {
89    for (const m of added.matchAll(SEE_OUR)) {
90      hits.push({ rule: '"see our" link phrasing', detail: snip(added, m.index ?? 0, m[0].length) })
91    }
92
93    // A Sources block is a list of links by design, so it is never a link
94    // library. Strip it when the edit carries the tags, and count only
95    // internal links (](/...), since sources are always external.
96    const prose = added.replace(/<Sources>[\s\S]*?<\/Sources>/g, '')
97    for (const para of prose.split(/\n\s*\n/)) {
98      const links = para.match(/\]\(\//g)?.length ?? 0
99      const bare = para.replace(/\[[^\]]*\]\([^)]*\)/g, '').replace(/[\s.,;:]+/g, ' ').trim()
100      if ((links >= 3 && bare.length < 120) || (links >= 2 && CLOSER.test(para))) {
101        hits.push({ rule: 'link-library paragraph', detail: snip(para, 0, 60) })
102      }
103    }
104  }
105
106  return hits
107}
108
109function scanShell(command: string): Hit[] {
110  const hits: Hit[] = []
111
112  if (CROP.test(command) && FACT_PHOTO.test(command)) {
113    hits.push({ rule: 'cropping a fact photo', detail: 'fact photos keep their aspect; re-encode only' })
114  }
115
116  if (MOVE.test(command) && /\barchive[\/\\]/i.test(command)) {
117    const named = command.match(CYCLING)
118    if (named) {
119      hits.push({ rule: 'doc moved to archive/', detail: `${named[0]} stays in root` })
120    } else if (!/_COMPLETED_\d{4}-\d{2}-\d{2}\.md/.test(command)) {
121      hits.push({ rule: 'doc moved to archive/', detail: 'only an empty one-off plan leaves root, as <NAME>_COMPLETED_<YYYY-MM-DD>.md' })
122    }
123  }
124
125  return hits
126}
127
128function addedLines(before: string, after: string) {
129  const had = new Set(before.split('\n'))
130  // Blank lines always stay, so paragraph breaks survive the diff.
131  return after
132    .split('\n')
133    .filter(line => line.trim() === '' || !had.has(line))
134    .join('\n')
135}
136
137const str = (v: unknown) => (typeof v === 'string' ? v : '')
138
139async function flag($: EngineInterface, where: string, hits: Hit[]) {
140  if (hits.length === 0) return
141  await update($, flags, list => {
142    const fresh: Flag[] = hits
143      .map(h => ({ ...h, where }))
144      .filter(f => !list.some(o => o.rule === f.rule && o.where === f.where && o.detail === f.detail))
145    return [...fresh.reverse(), ...list].slice(0, MAX_FLAGS)
146  })
147}
148
149export const register: Register = on => {
150  let buildAsked = false
151
152  on('prompt.submit', async ($, e, next) => {
153    buildAsked = [...e.text.matchAll(ASKED_BUILD)].some(
154      m => !NEGATED.test(e.text.slice(0, m.index ?? 0)),
155    )
156    return next(e)
157  })
158
159  on('tool.call', async ($, e, next) => {
160    const args = e as unknown as Record<string, unknown>
161
162    if (e.tool === 'Bash' || e.tool === 'PowerShell') {
163      const command = str(args.command)
164
165      if (BUILD.test(runnable(command)) && !buildAsked) {
166        await flag($, e.tool, [{ rule: 'npm run build blocked', detail: 'you did not ask for a build this prompt' }])
167        return {
168          deny: 'zookeeper: `npm run build` is blocked unless the user asked for a build in this prompt. Installs stop at check-images.mjs and check-internal-links.mjs.',
169        }
170      }
171
172      await flag($, e.tool, scanShell(command))
173      return next(e)
174    }
175
176    if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'MultiEdit') {
177      const path = str(args.file_path)
178      let added = ''
179
180      if (e.tool === 'Edit') {
181        added = addedLines(str(args.old_string), str(args.new_string))
182      } else if (e.tool === 'MultiEdit') {
183        const edits = Array.isArray(args.edits) ? (args.edits as Record<string, unknown>[]) : []
184        added = edits.map(x => addedLines(str(x.old_string), str(x.new_string))).join('\n')
185      } else {
186        const before = await $.fs.read(path).catch(() => '')
187        added = addedLines(before, str(args.content))
188      }
189
190      const hits: Hit[] = []
191      if (isProse(path)) hits.push(...scanProse(path, added))
192      if (/\/archive\//i.test(norm(path)) && CYCLING.test(norm(path).split('/').pop() ?? '')) {
193        hits.push({ rule: 'doc moved to archive/', detail: 'cycling docs and CLAUDE.md stay in root' })
194      }
195      if (CROP.test(added) && FACT_PHOTO.test(added)) {
196        hits.push({ rule: 'cropping a fact photo', detail: 'fact photos keep their aspect; re-encode only' })
197      }
198
199      await flag($, short(path), hits)
200    }
201
202    return next(e)
203  })
204
205  on('turn.complete', async ($, e, next) => {
206    if (e.agentId === undefined) {
207      const text = e.answer.replace(/```[\s\S]*?```/g, '')
208      const hits: Hit[] = []
209
210      for (const m of text.matchAll(UTC_TIME)) {
211        const before = text.slice(0, m.index ?? 0)
212        const inParens = before.lastIndexOf('(') > before.lastIndexOf(')')
213        if (!inParens) hits.push({ rule: 'bare UTC time in chat', detail: snip(text, m.index ?? 0, m[0].length) })
214      }
215      for (const m of text.matchAll(ZONE_LABEL)) {
216        hits.push({ rule: 'zone label on my time', detail: snip(text, m.index ?? 0, m[0].length) })
217      }
218
219      await flag($, 'chat', hits)
220    }
221    return next(e)
222  })
223
224  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
225    const list = await read($, flags)
226    const below = await next(e)
227
228    if (e.props.hasSurvey || list.length === 0) {
229      return below
230    }
231
232    const { Box, Button, Text } = $.ui.resolve(e)
233    const more = list.length - SHOWN
234
235    const mine = (
236      <Box flexDirection="column">
237        <Box>
238          <Text color="cyan" bold>
239            Zookeeper{' '}
240          </Text>
241          <Text dimColor>
242            {list.length} flag{list.length === 1 ? '' : 's'}{' '}
243          </Text>
244          <Button key="clear" label="Clear" onPress={() => update($, flags, () => [])} />
245        </Box>
246        {list.slice(0, SHOWN).map((f, i) => (
247          <Text key={`f${i}`} wrap="truncate-end">
248            <Text color="cyan">{f.rule}</Text>
249            <Text dimColor> {f.where}: </Text>
250            {f.detail}
251          </Text>
252        ))}
253        {more > 0 ? <Text dimColor>+{more} older</Text> : null}
254      </Box>
255    )
256
257    return below ? (
258      <Box flexDirection="column">
259        {mine}
260        {below}
261      </Box>
262    ) : (
263      mine
264    )
265  })
266}
267
types/index.d.ts 8 lines
1export type Flag = { rule: string; where: string; detail: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    zookeeper: { flags: Flag[] }
6  }
7}
8