Guidance for working in this repository.
- Work on
dev, notmain. Develop and commit ondev; push toorigin/devfreely to save work. - Never push to
mainwithout explicit approval — that deploys the live site. Only when the owner says "push." - New posts default to
published: false(draft). Never publish (published: true) unless explicitly told. - Sync first, rebase always. Before starting work, make sure
devisn't behindmain; before any push,git pull --rebase. - Build before you trust it. Ephemeral sandbox with a UTF-8 build
quirk — see Local build. Always build (and
grep_site/) to confirm a change before committing.
Detail for each of these is in the sections below.
Personal website + blog for Ryan Abrams.
- Generator: Jekyll (v3.10, via the
github-pagesgem) - Hosting: GitHub Pages, built automatically on push
- Domain:
ryanabrams.com(apex), DNS + proxy via Cloudflare - Deploy branch:
main— pushing tomaintriggers the Pages build - Working branch:
dev— day-to-day changes happen here (see Branch workflow below) - Plugins:
jekyll-feed,jekyll-sitemap,jekyll-seo-tag
The build requires a UTF-8 locale. The SCSS comments contain ──
box-drawing characters; with an empty/POSIX locale the old Ruby sass
gem reads files as US-ASCII and the build fails with
Invalid US-ASCII character "\xE2". GitHub Pages uses UTF-8, so this is
only a local concern.
bundle install
LANG=C.UTF-8 LC_ALL=C.UTF-8 bundle exec jekyll build # or: serveIf bundle exec jekyll reports "command not found", invoke the gem's
exe directly:
LANG=C.UTF-8 bundle exec ruby "$(find / -path '*/gems/jekyll-*/exe/jekyll' | head -1)" build
Output goes to _site/ (git-ignored). Always build before pushing
non-trivial changes to confirm it compiles, then grep the relevant
file under _site/ to confirm the change actually rendered. Remember
that drafts (published: false) are excluded from the build, so a
draft's absence from _site/ is expected, not a failure.
Cloudflare sits in front of GitHub Pages and caches aggressively.
assets/css/main.cssis cache-busted with?v={{ site.time | date: '%s' }}in_includes/head.html, so CSS refreshes on each build.- HTML can be served stale. Cache purging is automated:
.github/workflows/cloudflare-purge.ymlfires on a successfulgithub-pagesdeployment and purges the Cloudflare cache via the API (needs repo secretsCLOUDFLARE_API_TOKEN+CLOUDFLARE_ZONE_ID; the token only needs Zone · Cache Purge). If something still looks stale, check that workflow's run in the Actions tab, or do a manual Cloudflare Purge Everything + hard-refresh. - The sandbox cannot fetch the live domain (
host_not_allowed); verify changes via a local build instead.
_config.yml— site config,url, plugins, avatar_layouts/—default.html(shell + all inline JS),post.html_includes/—head.html,nav.html,footer.html,post-share.html_sass/— partials;_variables.scssholds the theme tokensassets/css/main.scss— imports the partials (has Jekyll front matter)assets/images/—avatar.jpg,social-card.jpg(default OG image);blog/for post/hero images- favicons +
site.webmanifestlive at the repo root index.html(homepage),blog/index.html,projects/index.html,contact/,privacy-policy/,thanks/,thanks-booking/
- The
Projectscategory is the switch. A post whosecategoriescontainProjectsshows on/projects/and is excluded from the blog index and homepage feed. Everything else is a blog post. - Homepage (
index.html): hero (avatar + social links, each shown only if its matchingsite.*_urlis set), then a "Latest from the blog" feed — non-Projectsposts, capped at 3, followed by a permanent "More posts →" button to/blog/. - Blog index (
/blog/): all non-Projectsposts, with category filter pills auto-generated from the posts' categories (client-side JS filtering viadata-category/data-filter). - Projects (
/projects/): onlyProjectsposts, same card style. - Cards use the post's
imageif set, otherwise fall back to a cycling CSS gradient placeholder.
- CSS custom properties defined in
_sass/_variables.scss::rootis light,[data-theme="dark"]overrides. --invert/--invert-textare for high-contrast pill buttons (dark pill on light mode, light pill on dark) — use these, not--body, for button backgrounds.- Text tiers:
--body>--text-2>--text-3>--muted. - Theme is set before paint by an inline script in
head.html(readslocalStorage.theme, falls back to OS preference). - The theme toggle, mobile hamburger menu, copy-link, and auto
copyright-year scripts all live at the bottom of
_layouts/default.html.
- All interactive features are vanilla, inline JS in
_layouts/default.html(theme toggle, hamburger, copyright year, copy-link, cookie consent, contact form). No build step, no framework, no client-side package manager — keep it that way. - Don't add third-party JS for features. Share buttons use plain
intent URLs (LinkedIn/X/mailto) and the clipboard API with an
execCommandfallback — no SDKs. New features should follow suit. - Contact form → HubSpot Forms API (replaced Formspree). The form in
contact/index.htmlhas noaction; an inline handler indefault.htmlPOSTs JSON tohttps://{host}/submissions/v3/integration/submit/{portal}/{form}, then redirects to/thanks/. IDs live in_config.yml(hubspot_portal_id,hubspot_form_id,hubspot_api_host). Gotchas:- The portal is EU-hosted — the host must be
api-eu1.hsforms.com. Plainapi.hsforms.comsilently fails for this portal. - Input
nameattributes must be HubSpot internal property names (firstname,lastname,email,message) and must match the HubSpot form's fields exactly — HubSpot returns 400 (FIELD_NOT_IN_FORM_DEFINITION/REQUIRED_FIELD) for extra or missing fields. Adding a field here means adding it in HubSpot too. - Deliberately no HubSpot script and no HubSpot cookies, so this
doesn't touch the cookie-consent system. Don't "fix" this by adding
the
hbspt.forms.createembed — it renders in an iframe (unless Marketing/CMS Hub Pro+), which would discard the site's form styling and dark mode. - Trade-off accepted: no-JS visitors can't submit, and without the
HubSpot tracking cookie (
hutk) submissions aren't tied to a visitor session.
- The portal is EU-hosted — the host must be
- Analytics/advertising are the deliberate exception: Google Tag
Manager (
gtm.id=GTM-TCCWGMLK) is the only third-party tag, and it now holds both Google Analytics and an X (Twitter) advertising pixel. So the site is not "script-free" or privacy-neutral; any additional tags live inside GTM, not the repo. - GTM is consent-gated via Consent Mode v2 — it does NOT auto-load.
head.htmlsetsgtag('consent','default', …)with everything denied, then only defineswindow.loadGTM()(guarded by__gtmLoaded); nothing invokes it until the visitor grants a category. The consent script at the bottom ofdefault.htmlpushesgtag('consent','update', …)per category and callsloadGTM()only when analytics OR advertising is granted. There is no GTM<noscript>iframe (it can't honor consent). Don't reintroduce an auto-loading GTM snippet. - Two consent categories → Consent Mode signals: Analytics maps to
analytics_storage; Advertising maps toad_storage+ad_user_data+ad_personalization. GTM-side requirement: each tag must respect these — GA4 honorsanalytics_storagenatively, but the X/ad pixel (a custom tag) needs an Additional consent check requiringad_storagein GTM, or it'll fire regardless. The repo sends the signals; gating each tag is configured in the GTM UI. - Cookie consent UI:
_includes/cookie-consent.html(banner + settings modal with Necessary / Analytics / Advertising), styled in_sass/_cookie.scss, rendered only whensite.gtm_idis set. Buttons carrydata-ccactions (accept/reject/settings/save/close); the choice is saved inlocalStorageundercookieConsent({"analytics": bool, "advertising": bool}) and honored on return visits. An element withid="open-cookie-settings"(anywhere — e.g. the privacy page) reopens the modal; the handler no-ops if absent. Note: revoking after granting takes effect on the next page load (a loaded GTM can't be unloaded mid-page).
- Posts:
_posts/YYYY-MM-DD-slug.md,layout: post. Front matter:title,date,categories,excerpt,read_time, optionalimage. - Permalink is
/blog/:title/— the date affects ordering only, not the URL (the URL comes from the filename slug). - Project posts: include
Projectsincategories(see Pages & display logic for how that routes the post). - Drafts & publishing:
published: falsekeeps a post/page out of the build (not live, not in_site/);published: true(or omitting the line) publishes it. The owner frequently keeps drafts in the repo that aren't ready yet, so treat unfinished posts as drafts by default.- When asked to create a new post, always set
published: false. - Never set
published: trueunless the owner explicitly says to publish. Adding finished content to a post is not the same as publishing it — leave the draft flag alone until told otherwise.
- When asked to create a new post, always set
- Search visibility: site is indexable by default. Use
noindex: trueto keep a page out of search andsitemap: falseto keep it out ofsitemap.xml. Both are set onprivacy-policy,thanks, andthanks-booking. - Sitemap: a custom
sitemap.xmlat the repo root overridesjekyll-sitemap(the plugin defers when the file exists). It adds a<lastmod>to every URL: posts uselast_modified_ator theirdate; pages fall back tosite.time(the build timestamp), so pagelastmodreflects the last build, not the last content change. Setlast_modified_at:in a page's front matter to give it an accurate date. (GitHub Pages can't runjekyll-last-modified-at, so git-based per-page dates aren't available without moving to a custom Actions build.) llms.txt: an LLM-readable index of the site (llmstxt.org convention) at the repo root, generated by Liquid at build time — don't hand-edit the output. It lists blog posts, project posts, pages, and the RSS feed, and honors the same visibility policy as search: pages withnoindex: trueare filtered out, and drafts never reach the build. New posts/pages appear automatically. It carriessitemap: false(and, being a.txt, isn't insite.html_pagesanyway, so the custom sitemap skips it). Note: this is not the same as Cloudflare's Markdown for Agents (Accept: text/markdowncontent negotiation) — that's an edge feature requiring a Cloudflare paid plan and can't be done from a static GitHub Pages origin.- Social meta (Open Graph / Twitter): handled by
jekyll-seo-tag({% seo title=false %}inhead.html). Itsog:imagecomes frompage.imageonly — notsite.imageorlogo(logofeeds only the JSON-LD structured data). The site-wide default card lives atassets/images/social-card.jpg(1200×630 JPEG — JPEG, not WebP, for scraper compatibility) and is applied via a_config.ymldefaultsblock scoped totype: pages. That scope is deliberate: posts must keep their ownimage:(or none), because the post cards key offpost.image— broadening the default to posts would make every card show the social card instead of its gradient placeholder. A post with its ownimage:overrides the default for its ownog:image. - Featured images: 16:9 (e.g. 1600×900). Compress to WebP
(~60 KB) and store in
assets/images/blog/. Cards/hero useobject-fit: cover, so keep the subject centered. - Inline post images go in
assets/images/blog/too;.post-body imgmakes them responsive. Convert/compress these to WebP as well. - Image tooling is available. Python Pillow handles
conversion/compression (resize, JPEG/PNG → WebP). It's installable in
the sandbox via
pip install Pillow(PyPI is reachable; arbitrary hosts are blocked). Don't assume image work is impossible — it's how the hero and inline images here were compressed. - Image-sizing gotcha (hit twice): any raw
<img>needs an explicit CSS rule (max-width: 100%; height: auto) or it overflows and breaks layout on mobile. The covered cases are.post-body img(inline) and.feat-card .img img(homepage cards). Before adding an image in a new context, confirm a sizing rule exists for it — don't assume.
-
Create
_posts/YYYY-MM-DD-slug.md. The date sets ordering; the slug sets the URL (/blog/slug/). -
Start from this front matter — note
published: falseby default:--- layout: post title: "Post Title" date: 2026-06-09 categories: [Marketing Ops] # add "Projects" to route it to /projects/ excerpt: "One sentence for cards and social/search previews." read_time: 5 # image: /assets/images/blog/slug.webp # optional 16:9 WebP hero published: false ---
-
Write the body in Markdown; use
##/###for section headings. Inline images: optimize to WebP intoassets/images/blog/first. -
Build locally to confirm. Leave
published: falseuntil the owner explicitly asks to publish.
- Before doing any work on
dev, check it againstmain. Ifdevis behindmain, sync it first (bringmainintodev) before making changes — start every task from adevthat matchesmain. - Develop on
dev. Make and commit all changes ondev. mainis the deploy branch. Work ondevis NOT live until it reachesmain— GitHub Pages only buildsmain.- Never push
dev→mainwithout explicit approval. Pushing tomaindeploys to the live site, so it only happens when the owner explicitly says to. - When the owner says "push," push to
main. That means: mergedev→mainand pushmain(this triggers the deploy). Then keepdevandmainin sync so they don't drift. devandmainshould hold identical content; only diverge while a set of changes is in progress ondev.- Rebase before pushing. The remote is often ahead — the owner
pushes commits directly. Run
git pull --rebase origin <branch>before any push to avoid rejected pushes. - The sandbox is ephemeral. The container is cloned fresh each
session and reclaimed afterward; uncommitted/unpushed work is lost.
Commit and push to
origin/devto persist work across sessions — that's just saving, not a deploy, and needs no approval.
- Keep edits consistent with the existing style; reuse the theme tokens.
- Don't commit
_site/, caches,Gemfile.lock, or.claude/settings.local.json(see.gitignore). - Keep this file current. If anything surfaces during work that belongs here — a new convention, a recurring gotcha, a workflow rule, a non-obvious build/deploy detail — point it out and ask whether to document it in CLAUDE.md. Don't silently update this file; flag it and let the owner decide.