Skip to content

Sites

Conventions for every website an Inky Labs project serves, whatever it’s built with or hosted on. Stack covers what a site is built with, and Hosting where it runs.

Every site’s <title>s follow one pattern, so a tab, a bookmark or a search result reads the same whichever Inky Labs site it’s from:

Page <title> Example (SVRBC’s doreancon.org)
The home page <site name> — <tagline> #doreancon 2027 — The Lord Is Our Song
Every other page <page> · <site name> Hymnal · #doreancon 2027
  • The site name is the name the site goes by, as its header and manifest show it, not its domain. On a multi-tenant site it’s the tenant’s: 2027.doreancon.org is #doreancon 2027. A page may end with the manifest’s short_name instead, as svrbc.org’s pages end in · SVRBC, and a page every tenant serves alike, such as /_/, with the name they share: Special pages · #doreancon.
  • The home page says what the site is, after an em dash: a short phrase, not a sentence, and not Home. It’s what a search result leads with, and what tells a stranger why to click. Keep the whole title under about 60 characters, where search results cut titles off. The page’s description can say more.
  • · and — and nothing else: not |, - or :, so two separators can’t be confused and the home page never repeats the site’s name.
  • A count or a status goes in front, in parentheses: (3) Messages · Inky Labs. It doesn’t change the rest.

In an Astro site the shared layout builds the title from the page’s own (site.ts holds the name and tagline, as in the template). In a Starlight site, titleDelimiter: '·' in astro.config.mjs gives every other page its title, and the home page sets its own in its frontmatter, since Starlight would otherwise write the site’s name twice:

head:
- tag: title
content: Inky Labs Developers — The developer handbook

inkylabs-audit reads each site’s home page and its /_/ and warns about a title that doesn’t follow this.

Every site serves /_/, an index of its special pages: pages about the site, rather than its content. They’re for the people who maintain the site, and for anyone curious how it works. Every hostname a site answers serves them (on a multi-tenant site, every tenant), and they’re marked noindex.

/_/ names the commit the site was deployed from, linked to it on GitLab (https://gitlab.com/inkylabs/<project>/-/commit/<sha>), with the release’s tag when it has one. Anyone looking at the site can then tell what is live and read exactly the source behind it, without working it out from pipelines. It’s the deployed commit, not the newest on master: a merge without a release changes master but not the site.

The deploy job writes it in, not the build: the job knows the commit ($CI_COMMIT_SHA, and $CI_COMMIT_TAG for a release), and a build that contained it would differ on every commit, so the release could never reuse the build cached from master’s pipeline. The page carries a placeholder that the deploy job replaces in the built files, failing if it finds none. This handbook’s is the example: docs/_/index.md and the pages job in .gitlab-ci.yml.

/_/ starts with one page, which every site has:

  • /_/architecture, the architecture. How the site is built, deployed and served, with an SVG diagram: where the request goes (DNS, CDN, origin, any assets domain), how a change becomes a release, and links to the source repository and its infra/ records. The diagram is a file in the repository, inlined into the page so it follows the site’s light or dark theme. A merge request that changes the architecture updates the diagram too.

Others get added to /_/ as a site needs them. Prefix any new one with /_/ rather than inventing a top-level path, so it can’t collide with the site’s own pages. A site with analytics links the shared analytics site from its /_/ rather than showing its numbers itself.

SVRBC’s doreancon.org has the example: /_/architecture, from src/special/ and src/assets/architecture.svg.

inkylabs-audit checks that each site answers /_/ and /_/architecture, and that its /_/ links the commit it was deployed from.

Every Inky Labs site is a progressive web app: it installs from the browser to a phone’s home screen or a computer’s dock, and what a reader has already seen still opens offline. Few people will install most sites, but the same pieces make every repeat visit faster and a bad connection (a basement classroom, a conference hall) survivable, and they cost a site a manifest, some icons and a small script.

SVRBC’s analytics.svrbc.org is the example of a hand-written site: site/manifest.webmanifest, site/sw.js, icons/ and the //:icons and //:site rules in its BUILD.bazel. This handbook is the example of an Astro one. Sites follow when someone next works on them.

  • A web app manifest, /manifest.webmanifest, linked from every page (<link rel="manifest">): name, short_name, id, start_url and scope (/), display: standalone, and background_color and theme_color matching the site’s light theme.
  • Icons: 192 and 512 pixel PNGs for any, and a 512 pixel maskable one: the logo on a solid background, inside the middle 80% circle, so a phone’s mask (circle, squircle) never cuts it. The maskable one doubles as the 180 pixel apple-touch-icon, since iOS wants no transparency.
  • <meta name="theme-color"> twice, one for each color scheme (media="(prefers-color-scheme: dark)"), so the browser’s chrome matches the page.
  • A service worker at /sw.js, registered by the page’s own script.
  • Pages and data: network first. A reader online always gets the current page and the current numbers; offline, the copy last fetched. A page never seen before falls back to the cached home page.
  • Everything else: cache first (scripts, styles, fonts, images). They change only when the site is rebuilt, and a rebuild replaces the cache.
  • Versioned by the build. The build stamps sw.js with a hash of every other file it publishes, so any change to the site installs a new worker, which takes over at once (skipWaiting, clients.claim) and deletes the old cache. There’s no “a new version is available” prompt: the next page load is simply the new site.
  • Only the site’s own GETs. Anything cross-origin (PostHog, YouTube, fonts from elsewhere) and anything that isn’t a GET goes to the network untouched, and a site with an API never caches /api/*: an API answers for now, not for whenever the reader was last online. The one cross-origin exception is the site’s own data on a host of its own (SVRBC’s analytics.svrbc.org reads its data.json from CloudFront), which is network first, like its pages.
  • The precache is a short list: the pages a reader needs offline and their assets, not the whole site. A site with thousands of pages (every post) caches what readers open, as they open it.
  • Nothing else. No push notifications, no background sync, no install banner of our own (the browser offers installation itself): each would ask readers for something, and none has a use yet.
  • A hand-written service worker, no library. Workbox and the Vite and Astro plugins built on it generate one from configuration, but the Astro integration (@vite-pwa/astro 1.2.0, the latest on 2026-10-08) supports Astro only to version 5, and this handbook and its template are on 7; vite-plugin-pwa 2.0.0 was five days old. The behavior above is about 70 lines of JavaScript that anyone can read, with nothing to upgrade. Revisit if a site needs what Workbox is for (expiring caches, background sync).
  • The icons are built, not committed. A Bazel step renders the PNGs from the site’s SVGs with librsvg, pinned through nix_tools, so the logo changes in one place.
  • Checked in the build, and once in a browser. The site’s own test checks the manifest, the icon sizes, the stamp, and that every file the worker precaches exists (a missing one fails the worker’s install, and the site never works offline). Installability is checked in Chrome through the DevTools Protocol: Page.getInstallabilityErrors answers an empty list, and with the server stopped the pages still render, a page never seen as the home page. Stop the server: don’t emulate the network being off (Network.emulateNetworkConditions), which reaches the page but not the worker’s own fetches, so a worker that never works offline passes (SVRBC found this on 2026-10-09: a page never seen came back as the server’s 404).

This handbook (astro.config.mjs, BUILD.bazel):

  • The manifest and the worker are static files, in Astro’s publicDir (static/ here, public/ by default), which the build copies as they are.
  • A build hook completes the worker. Astro names the files it builds by their hashes (/_astro/common.D5jyn-XR.css), so no list written by hand stays right. An astro:build:done hook writes the precache list from what was built (every page and the /_astro/ files) and stamps the version, as the last hook, so it hashes the pages as they’ll be served. Cache first suits those hashed files: a name never changes what it holds.
  • The icons are copied into publicDir before astro build, in the //:site step, so the stamp covers them. A plain npm run build has none, so the build’s own link check passes over .png links, and //:site_test checks that the icons are there.
  • The tags in <head> are Starlight’s head option in a documentation site, and the shared layout’s <head> otherwise.

Untried so far: a multi-tenant site such as SVRBC’s doreancon.org, where each host is its own origin and so installs its own app, which suits per-year sites but wants each host’s manifest to carry its own name.

inkylabs-audit warns about a site that links no manifest, whose manifest lacks a name, start URL, standalone display or the 192, 512 and maskable icons, or that registers no service worker.

A site that counts its visitors uses PostHog, without cookies. No site has to have analytics, but one that does follows this, so every Inky Labs site’s numbers live in one place, read the same way, and none of them needs a consent banner.

Not yet set up for Inky Labs: there is no PostHog organization or project for its sites, and no analytics site. What follows is the standard to set them up by, with SVRBC’s setup as the worked example.

  • One PostHog project for every site, sites, in an Inky Labs organization on PostHog’s US cloud (us.posthog.com), on the free plan. PostHog records the host on every event, so a breakdown by $host splits the sites (and a multi-tenant site’s tenants), and every site uses the same key:

    posthog.init('phc_…', { // the sites project's key, once it exists
    api_host: 'https://us.i.posthog.com',
    defaults: '2026-08-30',
    cookieless_mode: 'always',
    person_profiles: 'identified_only',
    })
  • One project, so the free plan. It allows one project per organization (organizations_projects, limit 1, as SVRBC checked on 2026-10-05); its monthly allowance of events is on the billing page, beside what the sites use. A project per site would need the pay-as-you-go plan (six projects, a card on file) for nothing a $host filter doesn’t already give; SVRBC considered it and declined. A site that ever needs its own (other people’s access, its own retention) is the time to upgrade.

  • Cookieless, always. posthog-js runs with cookieless_mode: 'always': no cookies, no local or session storage, so nothing that calls for a consent banner. PostHog tells visitors apart by a hash it computes on its servers, which resets daily, so a returning visitor counts again the next day. The project has to allow it (Settings → Web analytics → Cookieless server hash mode, or cookieless_server_hash_mode: 2 through the API); without that PostHog drops every cookieless event, silently.

  • No session replay, nor anything else that needs browser storage. Clicks are autocaptured, and pageviews follow the router (defaults: '<date>' turns on capture_pageview: 'history_change'). To name a button in the reports, give it a data-ph-capture-attribute-* attribute rather than writing posthog.capture() calls.

  • Nothing is sent from local development: the client skips initialization in dev builds, so the numbers are visitors’.

  • No secrets. The project API key (phc_…) can only send events, and is meant to be public, so it goes in the site’s committed config, like Nuxt’s runtimeConfig.public.

  • One analytics site shows them all, in one place, with a filter for each site and host. SVRBC’s is analytics.svrbc.org (svrbc/analytics.svrbc.org); Inky Labs has none yet. A site is its registrable domain, so a new site appears there by itself, with nothing to add. A site links it from its /_/ (?site=<domain> opens on its own numbers); it doesn’t embed a dashboard of its own, and doesn’t need a /_/analytics.

  • It draws its own charts rather than embedding PostHog’s: an embed can’t be styled to match, and viewers can’t filter it. Every hour a Lambda function asks PostHog’s query API for the numbers, with a personal API key that can only read (Query: Read, kept in SSM Parameter Store), and writes them to data.json, which the page reads from CloudFront (Releases; SVRBC’s infra/analytics-svrbc-org-data/).

  • The numbers are public. Visitor counts for Inky Labs’ sites aren’t sensitive, so the analytics site is a public, developer-facing Pages site, and its data.json is public on CloudFront. If they ever had to be private, Pages access control alone wouldn’t do it: the data would have to move behind it too.

  • The project has no public dashboards. PostHog’s own dashboards are for its signed-in members; none is shared publicly.

SVRBC’s doreancon.org is the example of a site that counts its visitors: src/components/Analytics.astro.

One thing trips up a new project, such as the one Inky Labs will make:

  • A new project’s dashboards sit behind PostHog’s onboarding screen, which returns on every visit until onboarding is marked done. Skipping through it doesn’t always stick; marking it done through the API does: PATCH /api/environments/<id>/ with {"has_completed_onboarding_for": {"product_analytics": true, "web_analytics": true}}.

inkylabs-audit checks that a project using posthog-js runs it cookieless and sends to the shared project, and warns about other analytics packages.