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.
Page titles
Section titled “Page titles”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.orgis#doreancon 2027. A page may end with the manifest’sshort_nameinstead, 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’sdescriptioncan 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 handbookinkylabs-audit reads each site’s home page and its
/_/ and warns about a title that doesn’t follow this.
Special pages
Section titled “Special pages”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 itsinfra/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 site is a PWA
Section titled “Every site is a PWA”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.
What a site serves
Section titled “What a site serves”- A web app manifest,
/manifest.webmanifest, linked from every page (<link rel="manifest">):name,short_name,id,start_urlandscope(/),display: standalone, andbackground_colorandtheme_colormatching the site’s light theme. - Icons: 192 and 512 pixel PNGs for
any, and a 512 pixelmaskableone: 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 pixelapple-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.
How the service worker behaves
Section titled “How the service worker behaves”- 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.jswith 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 aGETgoes 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 itsdata.jsonfrom 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.
Decisions, and why
Section titled “Decisions, and why”- 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/astro1.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-pwa2.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.getInstallabilityErrorsanswers 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).
In an Astro site
Section titled “In an Astro site”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. Anastro:build:donehook 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
publicDirbeforeastro build, in the//:sitestep, so the stamp covers them. A plainnpm run buildhas none, so the build’s own link check passes over.pnglinks, and//:site_testchecks that the icons are there. - The tags in
<head>are Starlight’sheadoption 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.
Analytics
Section titled “Analytics”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$hostsplits 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 existsapi_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$hostfilter 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-jsruns withcookieless_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, orcookieless_server_hash_mode: 2through 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 oncapture_pageview: 'history_change'). To name a button in the reports, give it adata-ph-capture-attribute-*attribute rather than writingposthog.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’sruntimeConfig.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’sinfra/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.jsonis 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.