Static marketing site for On Target ABA. Deployed via Cloudflare Pages from this repo.
Quality care without the wait. ABA therapy & autism testing for kids in Cleveland, Columbus, and Salt Lake City.
website/
*.html 31 public pages (services, locations, blog, legal, …)
blog/post.html Runtime markdown renderer for /blog/posts/{slug}
assets/
css/app.css Design tokens, animations, components
js/
app.js Scroll reveal, sticky nav, mobile nav, FAQ, marquee mirror
header.js Shared announcement bar + nav + breadcrumb (active-page state)
footer.js Shared footer with the "Built by Shalom Karr" credit chip
leadbot.js Floating intake widget (lazy-loaded by app.js)
images/ Logo + photography
fonts/ Self-hosted Plus Jakarta Sans + Fraunces (woff2, Latin subset)
blog/
*.md 161 blog posts as markdown + YAML frontmatter
index.json Generated by scripts/build-blog-index.py
og/ Per-page Open Graph SVGs (generated by gen-og-images.mjs)
scripts/ Build-time scripts (see "Scripts" below)
_redirects Cloudflare Pages rewrites (pretty blog URLs)
_headers Security headers, MIME, cache policy
build.sh CF Pages build entry point
sitemap.xml, robots.txt Generated by scripts/build-sitemap.py
c8f5d3a1….txt IndexNow ownership-verification key file
billboard.svg Highway billboard concept
CLAUDE.md Per-project notes for future Claude sessions
README.md This file
- HTML + Tailwind CSS via CDN, configured inline per page.
- Custom CSS in
assets/css/app.cssfor design tokens, scroll animations, marquee, accordion, target/bullseye mark, lead-bot widget styles. - Vanilla JS, no framework. Header + footer injected into
<div id="site-header">/<div id="site-footer">placeholders. - Fonts self-hosted (Plus Jakarta Sans + Fraunces, woff2, Latin subset).
- Runtime markdown rendering for blog posts via
marked.min.js+DOMPurify(CDN).
| Token | Hex | Use |
|---|---|---|
| ink | #163243 |
Body type, footer |
| ink-soft | #34495E |
Secondary text |
| teal | #00B7EA |
Bright accent (links, ornaments) |
| teal-deep | #0E5E6E |
Body emphasis, dark gradients |
| coral | #E84F3B |
Primary CTAs, italic headline emphasis |
| sun | #F4C842 |
Highlights, announcement bar |
| sage | #C5E0D5 |
5th accent — sage-soft cards |
| cream | #FAF5E6 |
Warm page background |
| line | #E8DFD0 |
Hairline borders |
No build step is required to view the static pages. From website/:
python -m http.server 8000
# → http://localhost:8000(Opening index.html from file:// also works for everything except the blog renderer, which needs fetch() of the markdown files.)
CF Pages project settings:
- Root directory:
/website - Build command:
bash build.sh - Build output directory:
.
build.sh runs every push to main:
python3 scripts/build-blog-index.pynode scripts/gen-og-images.mjspython3 scripts/inject-seo.pybash scripts/setup-fonts.shnode scripts/selfhost-fonts.mjsnode scripts/add-skip-link.mjspython3 scripts/build-sitemap.pypython3 scripts/indexnow-ping.py(or--allifSITEMAP_FULL_PING=1)
_redirects rewrites /blog/posts/* → /blog/post. Don't use /blog/post.html as the destination — CF auto-strips .html, which would turn the rewrite into a 308 and drop the slug. The extensionless form resolves to post.html internally without touching the URL bar.
Everything under website/scripts/ runs at build time. Each is idempotent and safe to re-run.
| Script | Runtime | What it does |
|---|---|---|
build-blog-index.py |
Python | Scans assets/blog/*.md, parses frontmatter, emits assets/blog/index.json with { posts: […], categories: […] }. The blog landing fetches this; the post renderer uses it for "related posts." |
gen-og-images.mjs |
Node | Generates 1200×630 Open Graph SVGs into assets/og/ — one per blog post + one per main funnel page. Skips slugs whose SVG is newer than index.json. No npm dependencies. |
inject-seo.py |
Python | Re-injects schema.org JSON-LD @graph (Organization + MedicalBusiness + LocalBusiness for 4 clinics + WebPage + BreadcrumbList + Review + AggregateRating + per-page-type extras), OG/Twitter meta, canonical link, theme color. Idempotent via <!-- auto-seo-start --> / <!-- auto-seo-end --> markers. Per-page metadata lives in the SEO_PAGES dict. |
setup-fonts.sh |
Bash | Downloads Plus Jakarta Sans + Fraunces (Latin subset, weights 400-800) from Google Fonts and writes them to assets/fonts/{family}-{weight}.woff2. Skips files that already exist. |
selfhost-fonts.mjs |
Node | Alternative font-fetch path that parses Google's CSS response and downloads the woff2 URLs it references. Provides a fallback path if setup-fonts.sh fails. |
add-skip-link.mjs |
Node | Sweeps every HTML file and ensures it starts with <a href="#main-content" class="skip-link">Skip to main content</a> and that the first <section> after the header has id="main-content". Idempotent. |
build-sitemap.py |
Python | Reads assets/blog/index.json and the static-page registry inside the script, emits sitemap.xml (URLs use the production canonical domain) and robots.txt. |
indexnow-ping.py |
Python | POSTs recent URLs (default: lastmod within 14d) from sitemap.xml to api.indexnow.org. --all pings every URL. Non-fatal — a failed ping doesn't break the deploy. Key + key file live at the deploy root. |
These were run once during initial migration, their changes are baked into the HTML / assets, and they're no longer needed. If a future task needs the same shape, the description here is enough to rebuild it.
| Removed | What it did |
|---|---|
add-blog-nav.py |
Inserted a Blog link into the desktop nav on every page. Now lives in header.js. |
download-assets.ps1 |
One-shot PowerShell that walked a manifest of asset URLs and downloaded each to assets/images/. Originally used to mirror images off the legacy WordPress site. Replace with a 20-line script: Invoke-WebRequest in a loop. |
embed-jotforms.py |
Replaced placeholder <form> blocks on pre-intake-form.html, contact.html, and autism-testing.html with <script src="https://form.jotform.com/jsform/{ID}"> embeds. Form IDs are documented in CLAUDE.md. |
fix-encoding.ps1 |
Normalized every HTML file to UTF-8 + BOM, decoded mojibake (â†' → →), replaced literal Unicode glyphs with HTML entities. Re-create with a ReadAllBytes / WriteAllBytes PowerShell loop + a hex-codepoint glyph table. Only needed if agents touch HTML and re-introduce raw glyphs. |
qa-check.ps1 |
Swept HTML for broken local links, missing image references, and leftover hex from prior palettes. ~50-line PowerShell using regex + Test-Path. |
recolor.ps1 |
Case-insensitive regex sweep that maps legacy palette hex values to the current Figma palette across HTML/CSS/JS/SVG. Re-create from the swaps hashtable structure: each entry is '#OLDHEX' = '#NEWHEX'. |
swap-logo.ps1 |
One-time sweep that replaced the placeholder bullseye-and-text logo with <img src="/assets/images/footerImg.png"> in nav and footer. Header.js now owns the nav logo; footer.js owns the footer logo. Not needed again. |
sync-tailwind-config.ps1 |
Sweep that rewrote every page's inline <script>tailwind.config = {…}</script> block to match the current palette. Re-create with a single [regex]::Replace against the multiline tailwind.config pattern. |
- Key:
c8f5d3a1e947b2f6a4c1b9d8e6f3a2b5 - Key file:
website/c8f5d3a1e947b2f6a4c1b9d8e6f3a2b5.txt - Endpoint:
https://api.indexnow.org/IndexNow(fans out to Bing, Yandex, Seznam, Naver) - Override window: set
SITEMAP_FULL_PING=1in CF dashboard env vars to ping every URL on the next deploy.
/admin is a private Google-OAuth–gated UI for editing every page,
post, header, and footer in this repo without leaving the browser.
Sign in with any allow-listed Google account (configured via the
ADMIN_EMAILS env var). After OAuth, you land on a dashboard with four
tiles: Pages, Posts, Header, Footer. Every save becomes a Pull Request
on OnTargetDevs/OnTargetABA.com so the repo history is the audit log
— nothing pushes straight to main.
- Edit any text on the page. The editor walks the live page in an iframe and overlays Edit handles on every text-bearing element (h1–h6, p, li, td, button, a, span/strong/em). Click, type, press Enter to commit.
- Visual Tailwind styling. When you click into a text node, a floating toolbar offers bold / italic / underline / line-through / uppercase toggles, font-size cycle (xs–7xl), font-family (sans / display), brand color swatches, and border-radius. Changes are saved as a full Tailwind className string on the override and replayed at runtime on every public view.
- Mobile / tablet / desktop preview. Topbar viewport toggle resizes the iframe to 390 / 820 / full-width so you can verify layouts on every form factor without opening DevTools.
- Hide any section. Every
<section>,<aside>,<header>,<footer>,<article>,<main>gets a coral corner toolbar with Hide / Save as template / Replace / Insert after. - Save a section as a template. Captures the section's
outerHTMLintoassets/templates/sections/{name}.htmlvia PR. The Replace and Insert-after options on every section toolbar list every saved section template for one-click reuse. - Image upload. Every
<img>gets an Image handle. The modal lets you upload a file (committed toassets/images/uploads/{yyyy-mm}/via PR) or paste a URL. - Per-page SEO panel. Collapsible details box with title,
description, keywords, canonical, OG title/description/image,
Twitter image. Stored at
assets/data/pages/{slug}.seo.jsonand applied at runtime bypage-overrides.js. - Page registry actions.
/admin/pages.htmllists every page; per-row toggles for Hide, Draft, Delete. Each flips a flag inassets/data/pages.json(always written as{ schemaVersion: 1, pages: [...] }) and regeneratessitemap.xmlin the same PR so search engines drop hidden pages. - Blog post editor.
/admin/post-editor.htmlhas form-driven frontmatter (title, slug, date, category dropdown, author, excerpt, read_time, hero_image URL) plus a markdown textarea with livemarked.js+DOMPurifypreview. Save Draft or Publish both submit a PR; on merge, CF Pages rebuilds the blog index and sitemap. - Header / Footer editors. Form-driven, round-trips
assets/data/header.jsonandfooter.json. Drag-reorder for nav links and footer columns; per-page breadcrumb chain editor. - Drafts visible to admins on the real URL. When you toggle a
page to draft, the public gets 404, but a logged-in admin sees
the latest draft content at
/{slug}— no special preview path. Powered by the catch-allfunctions/[[path]].jsFunction.
Save in /admin
-> Function commits files to admin/<kind>-<slug>-<uuid> branch
-> Function opens PR with the change(s) + regenerated artifacts
-> validate-content workflow lints frontmatter + JSON
-> auto-merge-admin workflow squash-merges on success
(the free-plan equivalent of GitHub's native auto-merge,
which Pro-gates private repos)
-> push to main triggers CF Pages deploy
-> purge-cache workflow waits for the matching deploy then
purges the ontargetnotes.com Cloudflare zone
-> custom domain serves the new state
Typical end-to-end: 60–120 s. You can watch the PR list at
github.com/OnTargetDevs/OnTargetABA.com/pulls
if a change isn't appearing — merge conflicts or validation
failures stop here without affecting main.
Required env vars in the Cloudflare Pages dashboard (all marked as
secret_text — CF's API silently drops plain_text mixed
into a multi-value PATCH):
| Name | Purpose |
|---|---|
GOOGLE_CLIENT_ID |
Web OAuth client ID |
GOOGLE_CLIENT_SECRET |
Web OAuth client secret |
GOOGLE_REDIRECT_URI |
https://website.ontargetnotes.com/OAuth/Callback — case-sensitive, must match Google Cloud Console allow-list |
ADMIN_EMAILS |
Comma-separated Google accounts permitted to sign in |
JWT_SECRET |
64-char hex used to sign the ota_admin cookie. Rotate to invalidate all sessions. |
GITHUB_TOKEN |
Fine-grained PAT scoped to OnTargetDevs/OnTargetABA.com with Contents R/W + Pull requests R/W + Metadata R |
GITHUB_REPO |
OnTargetDevs/OnTargetABA.com |
GITHUB_BRANCH |
main |
| Symptom | Likely cause | Fix |
|---|---|---|
/api/auth/google returns 1101 Worker exception |
Env vars not set in CF dashboard | PATCH each one (all as secret_text) and trigger a fresh deploy |
Google says redirect_uri_mismatch after sign-in |
Case mismatch with the URI in Google Cloud Console | Console allow-list must read exactly https://website.ontargetnotes.com/OAuth/Callback (capital O and C) |
| Pages / posts list is empty in the admin | A Function tried to call the GitHub Contents API without the website/ prefix |
Already handled by REPO_PREFIX in functions/_utils.js; if you saw this, the deploy hasn't picked up the latest yet |
| Edits don't appear after merging | The Cloudflare CDN zone hasn't flushed | The purge-cache.yml workflow handles this automatically; if it didn't run, purge manually via the CF dashboard |
- Jotform IDs and other production integration IDs are documented in
CLAUDE.md. cookie-consent.htmlis a placeholder shell — the original is a Termageddon runtime embed; wire the real script before launch.- All forms use
onsubmit="event.preventDefault(); …"placeholder handlers on top of the Jotform iframe so the styled wrapper degrades cleanly.
In-depth references live under docs/:
docs/ADMIN_DASHBOARD.md— user-facing walkthrough of the/admindashboard: sign-in, editing pages, creating blog posts, and how each save becomes a PR.docs/DEPLOYMENT.md— Cloudflare Pages configuration, required environment variables and secrets, the build pipeline, and the rollback procedure.docs/ARCHITECTURE.md— how the static site, the admin dashboard, and the Pages Functions fit together (auth flow, draft-preview catch-all, PR workflow).docs/CONTENT_AUTHORING.md— blog frontmatter conventions, page templates, and editorial style notes.
- See
LICENSEfor the proprietary, all-rights-reserved license terms (© 2026 Shalom Karr). - See
CONTRIBUTING.mdfor the PR workflow, commit conventions, branch naming, and local development setup.
Content © On Target ABA, LLC. Markup and design © 2026.