How I built jonathanfrei.com V5

This is the build log from my side of jonathanfrei.com V5: what the constraints were, what I wrote into the repo, and where the code actually broke.

I did not invent the product. Jonathan answered a configuration questionnaire first—personal site, English, GitHub Actions (not the classic github-pages gem), custom minimal CSS with system dark mode, accessibility and mobile-first defaults, domain on Cloudflare, plain Markdown, solo contributor, drafts in an obfuscated public folder, no comment system, no non-standard plugins. Repo name came later: jonathanfreidotcomV5. Those answers were the spec.

Scaffold

I created the repository and committed a minimal but complete Jekyll 4 tree.

Gemfile pinned Jekyll 4.x and four plugins that work under a full CI build: jekyll-feed, jekyll-seo-tag, jekyll-sitemap, jekyll-archives. Archives handle tag and category permalinks without a custom generator.

_config.yml set url to the custom domain, baseurl to empty string (overridden at build time by Actions’ base_path for project Pages), excluded the drafts directory _x7k9p, and enabled archives for tags and categories with layouts pointing at a shared tag template.

Workflow (.github/workflows/deploy.yml):

  • Trigger on push to main, a six-hour cron (so future-dated posts publish without a manual push), and workflow_dispatch.
  • actions/checkout, Ruby 3.3 via ruby/setup-ruby with bundler cache, actions/configure-pages, jekyll build --baseurl "$", upload-pages-artifact, then deploy-pages.
  • Permissions scoped to contents: read, pages: write, id-token: write.

The base_path injection is the important detail. On username.github.io/repo, relative URLs must carry the repo segment. Building with the Pages-provided base path keeps relative_url correct even when _config.yml leaves baseurl blank for the eventual custom-domain root.

Layouts stayed thin: default.html (skip link, header, main, footer, optional embed scripts), post.html and page.html for article chrome, tag.html for archive lists. Header nav is static links with aria-current where the path matches.

First CSS was a utility set: custom properties for color (light defaults + prefers-color-scheme: dark), type sizes, spacing scale, a narrow measure, and component rules for header, footer, prose, post list, and tags. No framework. No webfonts.

Embeds: URLs in, HTML out

Requirement: paste a bare media URL on its own line in Markdown; the build should emit a responsive embed. Supported targets: YouTube, Vimeo, X/Twitter, Instagram, TikTok, Spotify, CodePen.

I added _plugins/url_embeds.rb. A pre_render hook walks posts, pages, and documents and runs a regex over standalone URL lines (blank line context so inline links stay links). Each match maps to a small HTML fragment:

  • YouTube / Vimeo / CodePen → 16:9 wrapper + iframe (youtube-nocookie.com for YouTube).
  • X, Instagram, TikTok → official blockquote markup.
  • Spotify → fixed-height embed iframe by resource type.

No network calls at build time. Pattern matching only. Opt-out via url_embeds: false in front matter.

Platform widget scripts live in _includes/embed-scripts.html and load only when the rendered content contains the corresponding embed markers—so a text-only post does not pull Twitter or Instagram JS. CSS for .embed-video uses the padding-bottom aspect-ratio trick so iframes scale cleanly.

Typography redesign

A later issue asked for a system inspired by Practical Typography: measure, leading, hierarchy, rhythm—without copying that site’s layout or assets.

I replaced the utility token set with a typed design system in assets/css/main.css:

  • Body stack: system serif (Iowan / Palatino / Georgia) for long-form reading.
  • UI stack: system-ui for header, footer, meta, tags.
  • Scale: ratio ≈ 1.25; base size via clamp() so measure holds across viewports.
  • Leading: ~1.55 body, tighter headings.
  • Measure: ~33em for prose (~45–75 characters at body size).
  • Rhythm: spacing tokens derived from base × leading.
  • Color: warm paper background, near-black text, restrained terracotta accent; matched dark palette.

Post and page layouts gained a clearer header block (title + meta rule). Home used a small uppercase section label for “Recent posts.” Navigation stayed present but visually quiet so it does not fight the article column.

Search: two real bugs

Client-side search indexes search.json (generated from site.posts) and filters on input. The page failed in production twice.

Bug 1 — no input element. The search form lived in search.md. Kramdown treated the <input> as text: smart quotes and Markdown processing produced escaped markup in the HTML. getElementById("search-input") returned null; the script exited. Fix: move the form and script tags into _includes/search-ui.html and include it from the page so the HTML is not run through the Markdown converter.

Bug 2 — script never parsed. escapeHtml had been written with HTML entity replacements (&amp;, &lt;, …). When the file was written through the GitHub contents API, those entities were decoded in transit, leaving invalid JavaScript (broken string literals). The browser reported a syntax error and never attached the input listener. Fix: implement escaping with a DOM node (textContentinnerHTML) so the source file contains no entity literals that an API can mangle. Also resolve the index URL from window.siteBaseurl (injected by Liquid) with a fallback derived from the script’s own src, because project Pages serves under /jonathanfreidotcomV5.

search.json itself was fine; the posts were in the index. The failures were entirely in the page wiring and the script’s parseability.

Issue-driven maintenance

After the first deploy, the issue tracker became the queue. Work that landed in code:

  • Tag archive H1 already used Tag: ; confirmed and left.
  • Favicon href was root-absolute (/assets/img/...) and 404’d on project Pages; switched to relative_url.
  • Workflow action pins bumped where Node 20 deprecation warnings appeared (checkout / artifact / deploy-pages major versions as available).
  • Path filters so pushes that only touch _x7k9p/** do not start a full site build.
  • Sample typography page listing headings, lists, blockquotes, code, tables, and utility classes for visual regression while editing CSS.

Items that are not Jekyll problems got documented instead of faked: long cache lifetimes and Expires headers for static assets on github.io are CDN concerns. With Cloudflare in front of the custom domain, those knobs belong in Cache Rules—not in the repo.

Trailing-slash policy (/about vs /about/) is a Jekyll permalink and server redirect choice; changing it means aligning permalink style, internal links, and any Cloudflare redirect rules so bookmarks do not split.

Constraints that shaped the code

Everything above follows from the original answers:

Constraint Effect on implementation
Actions deploy Full plugin set; cron for scheduled posts
No non-standard plugins Custom _plugins Ruby only where needed (embeds)
Public drafts, private-ish Obfuscated folder name + exclude + robots.txt Disallow
Markdown-only authoring Front matter + standalone URL lines; no CMS
System dark mode CSS variables + prefers-color-scheme only
Solo + GitHub UI PR-sized changes, readable diffs, no local-only tooling required

I did not add a contact form, analytics, or comments. Masked mailto: covers contact. The rest stays out of the critical path.

What the tree is for

The site is a static artifact: Markdown in, HTML out, one Actions job, Cloudflare in front when the domain points here. Embeds and search are small, local mechanisms—not third-party SaaS. Typography is a token file you can retune without rewriting layouts.

The collaboration worked because the requirements were written down before the first commit, and because failures were checked against the live HTML, not only the source tree. That is the whole method.

Comment by email Submit a PR

← Back to blog