An offline-first PWA for handheld walking tours — hillforts, henges, barrows, and heritage trails. Tours are authored in Markdown, work without a network connection once installed, and install directly to an iOS or Android home screen.
Built with Svelte 5 · Vite 7 · vite-plugin-pwa / Workbox · three-way Light / Dark / Night theming.
Requires Node.js 20+.
npm install # install dependencies
npm run demo:seed # generate placeholder media for the demo tour
npm run dev # dev server at http://localhost:5173
npm test # run unit tests (vitest)
npm run check # type-check (svelte-check + tsc)
npm run build # production build → dist/
npm run preview # serve dist/ locally to test PWAOr in one command: npm run demo (seed + dev server).
content/ # Tour content — edit this to add tours
├── routes/
│ └── cissbury-ring/
│ ├── tour.yaml # Route manifest (stop order, metadata)
│ └── stops/
│ ├── cissbury-entrance.md
│ └── cissbury-summit.md
public/ # Static assets copied verbatim to dist/
├── CNAME # Custom domain — do not delete
├── icon-192x192.png
├── icon-512x512.png
└── tours/
└── cissbury-ring/ # Demo media — gitignored, generated by npm run demo:seed
└── … # Real tour media goes here and IS committed
src/
├── styles/
│ └── brand.css # All colour + font tokens (edit to rebrand)
├── lib/
│ ├── content/
│ │ └── vite-plugin.ts # Build-time content pipeline + base-path rewriting
│ ├── geo/
│ │ └── store.ts # Geolocation + Haversine proximity
│ ├── theme/
│ │ └── store.ts # Light / Dark / Night theme store
│ ├── types.ts # Shared TypeScript types
│ ├── TourLibrary.svelte # Home screen — tour cards
│ ├── RouteMap.svelte # Route overview — inline SVG map
│ ├── TourStop.svelte # Stop screen — content + media + proximity
│ ├── HengeLogo.svelte # Brand mark
│ └── ThemeToggle.svelte # Light → Dark → Night toggle
├── App.svelte # Hash router + visited-stops tracking
└── main.ts # Entry point + PWA registration
.github/workflows/deploy.yml # GitHub Pages auto-deploy (push to main)
scripts/seed-demo.mjs # Generates placeholder media for the demo tour
vite.config.ts # Vite + content plugin + PWA config
See AUTHORING.md for the full step-by-step guide covering:
- Setting your app identity (name, manifest, title)
- Rebranding in one file (
src/styles/brand.css— Light, Dark, Night token sets) - Tour folder layout,
tour.yamlfields, stop frontmatter reference - Inline media syntax (
→ right element by extension) - Demo tour:
npm run demo:seedgenerates placeholder media for every type (image, audio, video, 3D)
Three themes — Light, Dark, Night — toggled by the sun/moon button in the header. Night mode uses red-amber only to preserve dark-adapted vision in the field.
All colour and font values live in src/styles/brand.css. Edit that file to rebrand; no component
files need to change. The active theme persists to localStorage.
The build outputs a static site in dist/. It works on any static host.
The default build serves from / (root). If you ever need to host under a sub-path, set
BASE_PATH at build time — the content pipeline will prefix all media paths automatically:
# Root-domain or custom subdomain (default)
npm run build
# Sub-path host (e.g. /myapp/ on a shared server)
BASE_PATH=/myapp/ npm run buildA workflow is included at .github/workflows/deploy.yml. It triggers on every push to main,
generates demo media, builds, and deploys to Pages.
One-time setup: go to Settings → Pages → Source → GitHub Actions.
Custom domain: add your domain in Settings → Pages → Custom domain, and ensure a CNAME
file containing your domain name exists in the public/ folder (already present as
public/CNAME). This prevents the custom domain from being wiped on each deploy.
After that, every git push origin main deploys automatically.
Drag-and-drop (instant, no account connection needed):
npm run demo:seed && npm run build
# Drop the dist/ folder onto app.netlify.com/dropRepo-connected auto-deploy:
- Build command:
npm run demo:seed && npm run build - Publish directory:
dist - No base-path override needed (Netlify serves at root).
No _redirects file is needed — the PWA uses hash routing.
Drag-and-drop:
npm run demo:seed && npm run build
npm i -g vercel
vercel --prod # point at the dist/ folder when promptedRepo-connected: connect the GitHub repo in the Vercel dashboard, build preset Vite, build
command npm run demo:seed && npm run build. No base-path override needed.
Once deployed, any visitor can install the app to their home screen. It then launches full-screen with no browser chrome, and works completely offline.
- Open the tour URL in Safari (Chrome on iOS cannot install PWAs).
- Tap the Share button (the box with an upward arrow) in the bottom toolbar.
- Scroll down and tap Add to Home Screen.
- Edit the name if you like, then tap Add.
The app icon appears on the home screen. Open it to trigger the first offline cache (this requires a connection). After that, it works without signal.
Tip for tour leaders: Share the URL with participants before the walk and ask them to install it on Wi-Fi. The app caches all stops, fonts, and media — around 1–3 MB for a typical tour.
- Open the tour URL in Chrome.
- Tap the ⋮ menu (top-right) and choose Add to Home screen (or Install app).
- Tap Install.
On some Android devices Chrome shows an install banner at the bottom of the screen automatically after a few visits.
- Install the app or open it in Chrome desktop.
- Open DevTools → Application → Service Workers — confirm the SW is registered and active.
- Open DevTools → Network → Offline (tick the checkbox).
- Reload and navigate — all stops and media should load from cache.
See AUTHORING.md → Starting a new tour from scratch
for the full skeleton: a minimal tour.yaml, a stub stop file with every
required/optional frontmatter field annotated, and notes on the media folder.
Quick summary:
- Create
content/routes/<tour-id>/tour.yamlandstops/<stop-id>.md. - Add media (images, audio,
.pmtilesbasemap) topublic/tours/<tour-id>/— commit these. - Rebuild:
npm run build && npm run preview.
The tour appears automatically in the library — no code changes needed.
Don't copy the Cissbury folder as a starting point. Its content is a demo and carries
demo:seed-generated placeholders. Use the AUTHORING.md skeleton instead.
Survey export zips are produced by the companion field-survey app,
survey-tool — use it to
capture stations (GPS position, notes, photos, voice notes) in the field,
then export a session as a zip. If your content comes from that app
(session.geojson + photos/ + audio/ per session), you don't have to
build the tour skeleton by hand:
npm run tour:import -- ./my-survey-export.zipYou'll be prompted for a human-readable tour name, a route id, an optional
subtitle, and an optional one-line tour description, then it generates
content/routes/<id>/tour.yaml and one stop .md per surveyed observation.
Yes, it imports the photos — every photo for a stop is copied into
public/tours/<id>/ as a real file ready to commit (not a placeholder) and
appended inline in the stop's body. There's no separate hero image: on
phone, the hero plate is a live locator map, not a photo, so a "hero" photo
would be invisible where the tour is actually walked — see
AUTHORING.md → Importing a survey export
for why and for the full field-by-field mapping. Voice notes are reported
and skipped (not imported).
Nothing it produces is finished — every field that needs prose is a literal
TODO, except description if you supplied one at the prompt. Open the
generated files and fill in:
tour.yaml—description(if you skipped the prompt),subtitle,total_distance,duration, and (once you have a.pmtilesfile) uncomment themap:block.- Each stop file — rename the placeholder
title: "Stop 1"to something real, then fill inera,walk_time,evidence,interpretation, and photo captions. The body opens with the surveyor's field note as a first draft — rewrite it into proper stop prose.
Then npm run check && npm run test — the new route is picked up by the
existing content tests automatically — and npm run dev to see it in the
library.
The importer bakes several values into the generated files at import time. Renaming things afterwards can quietly break what it wrote:
- Renaming the route id (the
content/routes/<id>/public/tours/<id>folder name) breaks every generated path — inline photosrcand (once uncommented)map.basemapall point at/tours/<old-id>/.... Rename both folders together and find/replace the old id across every stop file. - Renaming a stop's
id:(e.g.stop-1→ something descriptive) must be mirrored intour.yaml'sstops:list entry and ideally theNN-<id>.mdfilename — the usual three-places rule for any stop. - Renaming a stop's
title:(e.g.Stop 1→ a real name) does not rename itsid:, its filename, or its photo files — those staystop-1/01-stop-1.md/01-stop-1-N.jpgregardless of title. This is cosmetic only (the body's photo paths still point at the right file), but can be surprising if you go looking for a file matching the current title. - Re-running the importer on the same zip with
--forceregenerates every file from scratch — it overwrites, it does not merge. Any hand-edits (prose, renamed ids/titles, an uncommentedmap:block) are lost, and because filenames are derived from ordinal position rather than content, a session that has gained or lost a feature since your last import can shift which photos land under which stop number. Only safe before you've started editing. route_name(the tour's display title) is freely editable with no knock-on effect — nothing else derives from it after generation.
npm test # run once
npx vitest # watch mode
npm run check # type check only| File | Covers |
|---|---|
src/lib/geo/store.test.ts |
Haversine distance · accuracy-aware proximity · createProximityStore |
src/lib/content/content.test.ts |
Windows-safe routeId · YAML manifest parsing · numbered-prefix regex |
MIT