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

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.
content/, compiled by @mdx-js/rollupsrc/lib/data/ (facts, encyclopedia, Beastlypedia, guides, affiliate products, legal status)public/_worker.js serving the API routes, the RSS feeds, and the care package checkout and download endpointsprerender.mjs plus scripts/generate-fact-pages.mjs, so every article and fact page is static HTML before hydration├── 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
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.
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:
| Command | What it enforces |
|---|---|
npm run check:links | every article carries 1 to 2+ in-body internal links, to valid targets |
npm run check:voice | the voice rules in docs/RULES.md, against scripts/voice-baseline.json |
npm run check:costs | cost guides cover the required line items |
npm run check:affiliate | affiliate articles pair affiliate: true with the disclosure |
npm run check:related | new articles are wired into relatedArticles.js, no dead slugs |
npm run check:seo | seoTitle and seoDescription length and presence |
npm run check:images | every referenced image exists and has dimensions |
npm run check:sources | source URLs in Sources blocks still resolve |
npm run check:numbers | species numbers stay consistent across guides |
npm run check:encyclopedia | encyclopedia entries match their guides |
npm run check:legal-sync | the legal map matches legalStatus.json |
npm run check:link-depth | internal 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.
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:
content/_scheduled-*.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 handlingThe 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.
Built with care for animals and the people who love them.
hooks/register.tsx 267 lines1import { 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}
267types/index.d.ts 8 lines1export type Flag = { rule: string; where: string; detail: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 zookeeper: { flags: Flag[] }
6 }
7}
8