Skip to content
Jpshlk.

How this site works

A guided tour of this site's anatomy that starts from zero. If you are not quite sure what a browser even does, start at section 1 and read straight down: every idea is explained the first time it shows up. If you build websites for a living, skim the table of contents and jump to the parts about this site. Either way, no web dev background needed.

component (.astro file)a <slot />: a hole content falls intoscript or head element
On this page

The foundations (start here if the web is new to you)

  1. What a website actually is
  2. What happens when you visit jpshlk.com
  3. The toolbox on my side

This site

  1. The big picture: build time
  2. Slots in sixty seconds
  3. The homepage, as nested boxes
  4. A blog post: layouts nest
  5. Where the data comes from
  6. How a post gets written
  7. Images: three jobs, one build
  8. How it looks: Tailwind
  9. Search without a server
  10. Dark mode in three parts
  11. What happens on a click
  12. Analytics that do not follow you
  13. Comments via GitHub Discussions
  14. Pages for robots
  15. From my laptop to your screen

1What a website actually is

Your browser (Safari, Chrome, Firefox) is a program with one job: download files of plain text from other computers and turn them into the pages you see. Nearly every page on the internet, this one included, is written in three languages, and each one has exactly one job. Here is the same tiny page in all three.

HTML: structure + words

<h1>Hi, I'm Josh</h1>
<p>Welcome to my site.</p>
<button>Say hi</button>

Tags label what each piece is: a heading, a paragraph, a button. No styling, no behavior, just meaning.

CSS: how it looks

h1 { color: seagreen; }
p { max-width: 60ch; }
button {
  border-radius: 8px;
}

Rules that point at the HTML and describe its appearance: colors, sizes, spacing, rounded corners.

JavaScript: what it does

const btn = document
  .querySelector('button');
btn.addEventListener('click',
  () => {
    alert('Hi! đŸ€™');
  });

A real programming language the browser runs, for anything that reacts or changes after the page appears.

Want to go deeper? MDN Web Docs is the closest thing the web has to an official manual, and it is free. Its reference pages for HTML, CSS, and JavaScript back up everything on this page.

2What happens when you visit jpshlk.com

Typing an address and seeing a page feels instant, but there is a round trip hiding in there. Here it is in order; the whole thing usually takes a fraction of a second.

Your browser

you type jpshlk.com

first stop: find out where that name actually lives

who is jpshlk.com?

DNS

the internet's phone book

turns the name into a machine address (an IP address)

ask that address

A server

a computer that answers requests

sends back the HTML, then the CSS, images, and scripts it mentions

files arrive

The page you see

rendered by your browser

HTML parsed, CSS applied, scripts run: section 1's three jobs

A server is not special hardware: it is any computer whose job is answering requests. Many sites make theirs assemble each page fresh, on demand, for every visitor. This site did all that work ahead of time (section 4), so its "server" is really a CDN, a delivery network that just hands over finished files from a data center near you (section 18). That is most of why it feels fast.

Want to go deeper? MDN explains how the web works and what DNS is in beginner terms.

3The toolbox on my side

Everything from here on mentions tools that run on my computer, not yours. Four of them do almost all the work, so here is the one-line version of each. You will recognize them for the rest of the tour.

The terminal

typing commands instead of clicking

A window where you run programs by typing their names. Nothing magic: every command on this page, like npm run build, is typed here.

Node

JavaScript, outside the browser

JavaScript was born inside browsers. Node lets it run on any computer, which is how most website build tools (Astro included) are written.

npm

an app store for shared code

npm installs the open source packages this site depends on, and runs named commands: npm run build just means "npm, run the command I named build".

Git

save points for a whole folder

Git records named snapshots of every file in a project, forever, so nothing is ever lost and any change can be undone. Its full story is in section 18.

4The big picture: it all happens at build time

Two definitions first. A framework is a kit of pre-solved problems: instead of every site owner hand-writing the machinery for layouts, navigation, image resizing, and feeds, the framework supplies the machinery and you supply the content. This site's framework is Astro.

Astro exists because of a bet about timing. Many modern frameworks ship a JavaScript app to your browser, which then assembles the page in front of you. That is work done at runtime: while you watch, on your device. Astro's bet is the opposite: do all the assembling once, ahead of time, and ship the finished result. That ahead-of-time step is the build, and "build time" just means "while that step is running".

So the Astro components you will meet below are never sent to visitors. They run once, on my machine or Netlify's, at build time. What comes out is plain HTML files. That is why the site is fast: your browser gets finished pages.

The source

src/

content/ (MDX posts, tags, projects)

pages/ (one file per route)

layouts/ + components/

npm run build

The built site

dist/

index.html, blog/…/index.html

rss.xml, sitemap, llms.txt

optimized images + one CSS file

pagefind/ (the search index, see 12)

The trade-off, honestly stated: a build-time site cannot show you a shopping cart or a personalized feed, because every visitor gets the same files. For a blog, that is no trade-off at all: the content is the same for everyone anyway.

5Slots in sixty seconds

This whole site is assembled from components: named, reusable chunks of page that you use like custom HTML tags (<Header />, <PostCard />). And the trick that makes layouts work is the <slot />: a hole. Whatever you write between a component's opening and closing tags falls through into that hole. That is the entire trick.

BaseLayout.astro declares the hole

<html>
  <head>…</head>
  <body>
    <Header />
    <main>
      <slot />  ← the hole
    </main>
    <Footer />
  </body>
</html>

index.astro fills the hole

<BaseLayout title="Josh Pasholk">
  <Hero />
  <WorkSection />
  <LatestPosts /> ← all of this lands
     where the slot was
</BaseLayout>

So every page gets the same head, header, and footer for free, and only has to bring the middle. Change BaseLayout.astro once and every page on the site changes. There is one escape hatch: pass BaseLayout a chromeless prop and it skips the header and footer while keeping the whole head intact. The link-in-bio page uses it to stand alone. Astro's own components guide covers this in full.

6The homepage, as nested boxes

Boxes inside boxes literally is the architecture. Solid boxes are components; the dashed region is BaseLayout's slot, filled by whatever index.astro put between its tags.

BaseLayout

src/layouts/BaseLayout.astro (owns <html> and <head>)

theme script (is:inline)<Seo /> meta + JSON-LD<ClientRouter />Pagefind loader script

Header nav + search button and modal + ThemeToggle + mobile menu

<slot /> : filled by src/pages/index.astro

Hero components/home/Hero.astro

WorkSection reads the projects collection

LatestPosts

components/home/LatestPosts.astro (newest 5 posts)

PostCard × 5 each renders TagPill + FormattedDate

Footer RandomQuote + SocialIcon row (github, linkedin, bluesky) + links + ©

7A blog post: layouts nest, so slots nest

A post page uses two layouts. PostLayout sits inside BaseLayout's slot, and declares a slot of its own, which is where the actual writing (the rendered MDX) lands.

BaseLayout

same shell as the homepage: head, Header, Footer

<slot /> : filled by PostLayout

PostLayout

src/layouts/PostLayout.astro

breadcrumb trail: Home â€ș Blog, same data as the page's JSON-LD (Breadcrumbs.astro)

post header: FormattedDate, title (view-transition name), TagPills

<slot /> : filled by <Content />, the rendered MDX

Prose wrapper typography styles for the article body

the MDX itself can import components: 11 posts pull in Link.astro and SocialIcon

prev / next post links

GitHub buttons script (data-astro-rerun)

Who fills what: the route file src/pages/blog/[...slug].astro looks up the post, calls render(post) to turn MDX into <Content />, and writes <PostLayout post={post}><Content /></PostLayout>. One file, two slots filled.

8Where the data comes from: props go down

Components never go looking for data. Data (post titles, dates, tags) is loaded once, then handed down the tree as props: the values a component receives when you use it, like arguments to a function. Writing <PostCard post={post} /> hands PostCard one post to render. One-way traffic, top to bottom.

MDX files

src/content/blog/*.mdx

frontmatter + body
schema validates

Typed collection

src/content.config.ts

title, date, tags…
sort + filter drafts

Helper

src/lib/posts.ts

getPublishedPosts()
props

Component

<PostCard post={post} />

renders title, date, tags

The same pipeline feeds the blog list, the tag pages, the RSS feed, the landing page's LatestPosts, and the SEO schema endpoints. One source of truth, many consumers.

The TypeScript aside: this site is written in TypeScript, which is JavaScript plus types: labels describing what shape data must have ("a post has a title, and it is text"). The "schema validates" arrow above is that idea applied to content, so a typo gets caught while I am writing, not after publishing. Astro calls this whole pipeline content collections.

9How a post gets written

Every post is one file, written in Markdown: a way of writing formatted text with punctuation instead of buttons (# makes a heading, **bold** makes bold). Strictly speaking the files are MDX, which is Markdown that is also allowed to use the components from section 5. On top of the writing sits a small settings block called frontmatter. That block is data, not prose, and it drives everything the site knows about the post.

The settings block (frontmatter)

---
title: My Great Post
date: '2026-07-11'
summary: 'One or two sentences.'
tags: ['astro', 'apps']
draft: true  ← hides it from the live site
---

The actual writing starts here.

What each line feeds

title → the h1, the browser tab, search results

date → sorting, the archive, the RSS feed

summary → post cards and link previews

tags → must match a real tag page, checked at build

draft → true renders in dev only

The spell-checker: a schema in src/content.config.ts validates every settings block at build time. A misspelled tag, a bad date, a missing title: the build fails and tells me which file, before anything reaches the internet.

Publishing is deleting one line. Drafts render on my machine in npm run dev so I can preview them; remove draft: true, push, and the post is on its way (section 18 covers that trip). If its date has already arrived it goes live on the next build. If the date is still in the future the post is scheduled: it waits, invisible on the live site, and a small robot checks every morning and rebuilds the site the day it comes due.

10Images: three jobs, one build

There are 36 original images in src/assets/, and posts reference them with plain markdown. Nobody resizes anything by hand; the build does all three image jobs. Why bother? Because images are the heaviest thing on most pages, and a phone on cell data should not have to download a four megabyte original to fill a small column of text. Shrinking them is the single biggest web performance win there is.

Originals

src/assets/

full-size photos + screenshots

referenced by 22 posts and Hero.astro

astro build

Optimized copies

dist/_astro/

resized, compressed, hashed filenames

share images: one per post, for link previews

Job one, content images: a markdown ![alt](../../assets/photo.jpg) in a post (or the <Image /> component in Hero) comes out the other side compressed and cache-friendly, automatically.

Job two, share images: PostLayout renders each post's feature image to a link-preview version at build time: capped at 1200px wide, never upscaled, and kept as PNG or JPEG because some link crawlers still choke on modern formats. And a post with no art at all gets a card invented for it: the build draws a dark 1200 by 630 title card (site name, title, date) and wires it up as that post's link preview.

Job three, the passthrough bucket: public/ is for files that must not be touched: favicons, robots.txt, the redirects file, and the default share image. They are copied into dist/ exactly as-is.

11How it looks: Tailwind and one CSS file

Section 1 said CSS is the looks. This site writes almost none of it by hand. Instead it uses Tailwind CSS: a big box of tiny pre-made class names, each doing exactly one thing, composed right in the markup instead of in separate stylesheets. Here is a real one, the pill-shaped chip used in the diagrams on this page.

The markup on this very page

<span class="rounded-full
  border bg-primary-50
  px-2.5 py-0.5
  font-mono text-xs
  text-primary-700">
  theme script
</span>

theme script (is:inline) ← the result

What each class means

rounded-full → fully round corners (the pill shape)

bg-primary-50 → the palest tint of the site's green

px-2.5 py-0.5 → a little padding, sideways and vertical

font-mono text-xs → small monospace type

text-primary-700 → darker green ink on top

One small file: at build time (section 4 again) Tailwind scans the whole codebase for class names it recognizes and writes a single CSS file containing only the ones actually used. Styles nobody uses never ship, and the whole site's look arrives in one small download.

Dark mode hooks in here: any class can be prefixed with dark:, as in dark:bg-zinc-900, to apply only when dark mode is on. What flips that switch is the next section. The site's design tokens live in src/styles/global.css: Tailwind v4 is configured in CSS itself, no separate config file.

The search button in the header (Cmd+K works too) and the /search/ page run on Pagefind, which indexes the finished HTML right after the build. An index is a pre-built map from words to the pages containing them, like the one in the back of a book: that is what makes search instant with no server involved.

Built pages

dist/**/*.html

only pages marked data-pagefind-body: posts, home, /about/, /projects/

pagefind --site dist

Search index

dist/pagefind/

index chunks + the UI bundle, all static files

your query

Your browser

no API, no server

downloads only the tiny chunks that match

A second kind of component: everything in sections 5 through 7 was Astro components, which run at build time and disappear. The search UI is the opposite: web components, custom HTML tags that a script brings to life in your browser. The header renders just this:

<pagefind-modal-trigger instance="modal" compact></pagefind-modal-trigger>
<pagefind-modal instance="modal" reset-on-close></pagefind-modal>

The empty <pagefind-modal> builds its own input, results, and keyboard hints once the loader script (that chip in section 6's diagram) fetches the bundle. In dev there is no index yet, so a CSS rule hides the undefined tags and the search page shows a fallback note instead.

13Dark mode in three moving parts

The theme toggle looks like one button, but making it flicker-free takes three pieces working together.

  1. One class rules everything

    Dark mode is just a dark class on the page's root element. Every dark style in the CSS (those dark: variants from section 11) keys off that single class, so adding or removing it re-themes the whole page instantly. No reload, no per-element logic.

  2. The no-flash trick

    A tiny script at the very top of the page reads your saved choice (or your system preference) and sets the class before the first pixel renders. Without it, dark mode users would see a white flash on every visit. It re-runs after each page swap too, because the incoming page arrives without the class.

  3. The toggle remembers

    Clicking the sun/moon flips the class and saves your choice in the browser's localStorage (a little notepad each site gets in your browser), so it sticks across visits. It also stamps adata-pf-theme attribute that the search UI (section 12) reads to switch its own dark scheme.

14The browser layer: what happens on a click

Almost everything above ran at build time. Here is the small amount of JavaScript that actually runs for you, the visitor. One idea first: normally, clicking a link throws the whole page away and rebuilds the next one from scratch. This site uses Astro's view transitions instead: it swaps just the content and animates the difference, which is why moving around feels smooth. Here is the sequence, in order, when you click a link on this site.

  1. First visit: theme script runs before paint

    The is:inline script in the head reads localStorage (or the system setting) and applies the dark class before any pixel renders. No flash.

  2. You click a link

    <ClientRouter /> intercepts it, fetches the next page's HTML, and swaps the document instead of doing a full reload. This is what enables the image and title morph between a card and its post or project page. The morph names are applied just in time by a script in the layout (only when one side is a detail page), so list-to-list navigations stay a clean crossfade instead of morphing every card at once. One deliberate exception: the header opts out of the crossfade (its transition is set to none), so the site name and nav links stay planted while the little pill highlight slides underneath them to whichever link is now active. The stylesheet also reserves a permanent gutter for the scrollbar, so landing on a short page (where the scrollbar would otherwise vanish) cannot nudge the whole layout sideways mid-swap.

  3. astro:after-swap fires

    The new document arrived without the dark class, so the theme script's listener re-applies it mid-swap. Still no flash.

  4. astro:page-load fires

    ThemeToggle and the mobile menu re-bind their click handlers to the fresh DOM, RandomQuote picks a new quote, the comments widget re-mounts on posts, and the GitHub buttons script re-runs on posts that need it. The Pagefind loader also runs here: it fetches the search components once per visit and prunes anything from their registry that the swap just removed, so the search button never points at a dead modal.

The house rule that keeps all this working: on a view-transitions site, any JavaScript that touches the page must run on astro:page-load, not just once at startup.

15Analytics that do not follow you

Analytics means counting visits. Most sites do it with cookies (small ID tags a site stores in your browser so it can recognize you later) and scripts that follow you across the web. I want to know if anyone reads this site, not who you are. Pageviews are counted by GoatCounter, an open source, privacy-first counter. That is why there is no cookie banner here: there is nothing to consent to.

What it sees

which page was viewed

which site linked here (the referrer)

rough totals, on a public-style dashboard

What it never does

no cookies, so nothing to accept

no fingerprinting, no ad networks

no following you to other sites

Only in production: the script renders only on real production deploys. Local dev and preview deploys ship literally zero analytics bytes, not a disabled script, no script at all.

One view transitions wrinkle: page swaps are not real page loads, so automatic counting is off and each swap is counted once on astro:page-load (the same event from section 14). Otherwise reading five posts would count as one view, or one view as ten.

16Comments via GitHub Discussions

There is no comment database here either. Each post's comments live in a GitHub Discussion on this site's repo, and a small open source widget called giscus shows that discussion under the post, inside an iframe: a sandboxed little window onto another website, embedded in this page. Reading costs nothing; replying takes a GitHub sign in, which handles spam and moderation for me.

How a comment gets there

you sign in with GitHub inside the widget

your comment lands in a Discussion named after the post's URL

the first comment on a post creates its Discussion automatically

What the page pays for it

nothing until you scroll near the comments (lazy loading)

then one iframe from giscus.app, sandboxed like any other

no tracking scripts, no ads, no cookies from this site

The same view transitions wrinkle as analytics: a swapped-in page never runs scripts baked into its HTML, so the widget is created fresh on astro:page-load (section 14 again) for every post you visit.

Dark mode reaches into the iframe: the widget mounts with whatever theme the page has, and when you flip the toggle, a tiny observer notices the class change and messages the iframe so the comments flip with it.

17Pages for robots

Humans get the HTML pages. The same build also writes files meant only for machines, and hires a few robots to proofread my work.

Machine-readable outputs in dist/

generated fresh on every build

rss.xml the whole post in the feed, not a teaser, so feed readers get everything

sitemap a map of every page, for search engine crawlers

JSON-LD in every head schema.org data describing the page, the site, and me, all agreeing with each other; the visible breadcrumb links on posts, projects, and tag pages render from the same data

llms.txt a curated text index of the real content, for AI tools that read sites

Never heard of RSS? It is the old, still-great way to follow sites without an algorithm in between: a feed reader app checks each site's feed file for you and shows new posts in order, like podcast subscriptions but for reading. About Feeds is a lovely five minute introduction. The JSON-LD blocks speak schema.org, the shared vocabulary search engines use to understand pages.

The proofreading robots: validators run during every build and FAIL it on a missing h1, a dead internal link, or a page title or description that is too long or too short. A broken link cannot reach the live site because the build that contains it never finishes.

18From my laptop to your screen

Nothing here uploads files to a server by hand. The whole publish flow is: change something, commit it (ask git for one of those named snapshots from section 3, with a note saying what changed), and push (upload the new snapshots to GitHub). Everything after that is automatic.

My laptop

(or an AI session)

edit, commit, git push

push

GitHub

the code's home

keeps history, notifies Netlify

webhook

Netlify

npm run build

Astro builds, Pagefind indexes, validators check

publish dist/

A CDN near you

jpshlk.com

static files, served fast, worldwide

The cast: GitHub is the website where the code and its entire history live; the arrow labeled webhook is literally GitHub tapping Netlify on the shoulder to say something changed. Netlify then re-runs the build from section 4 on a fresh computer and copies the result to a CDN: data centers around the world, each holding a copy of the finished files, so the site loads from somewhere near you.

A push is not the only trigger: every morning a scheduled check (a GitHub Actions robot) looks for posts whose publish date has arrived and, if one is due, pokes Netlify to rebuild. That is how a finished post from section 9 can go live on its planned date while I sleep in.

Safe experiments: only the main branch publishes to jpshlk.com. Work happens on other branches, and each one gets its own private preview URL built exactly the same way, so every change is reviewed on a real deploy before it merges. This very page was checked on a preview URL before you ever saw it.

One last thing: this page is itself a box in the section 6 diagram. You are reading it inside the very slot described in section 5. đŸ€™