Source for offon.dev, the home of OffOn: a platform for open source enthusiasts. The site is fully static with no backend. Pages are prerendered at build time by Astro; interactivity is added as .astro components with vanilla <script> blocks. It hosts hands-on open source challenges, community documentation, and links to the OffOn community.
- Astro 7 (
output: 'static') + TypeScript: prerendered pages, zero JS by default - Vue 3 via
@astrojs/vue: retained for future islands; the site currently ships zero islands — all interactive surfaces are.astro+ vanilla script - nanostores: shared store state (consent); read directly via
.subscribe()/.get()in inline scripts - Tailwind CSS 4: CSS-first via
src/styles/index.css(@theme) and@tailwindcss/vite - unplugin-icons (lucide) via
~icons/lucide/*; customabbr[data-title]tooltip portal inLayout.astro - Astro Content Collections (Zod): adventure content authored as YAML, validated + rendered at build time
- Playwright + axe: accessibility and SEO/smoke tests (
e2e/) - GitHub Pages: hosting and deployment
git clone https://github.com/off-on-dev/website
cd website
npm install
npm run dev # Astro dev server (http://localhost:4321)Node.js 26 is required (pinned in .nvmrc; nvm use).
| Script | Description |
|---|---|
npm run dev |
Astro dev server at http://localhost:4321 |
npm run build |
Static build to dist/ |
npm run preview |
Serve the built dist/ (astro preview) |
npm run sync |
astro sync — runs the Zod content schema over adventure YAML; fails on invalid content |
npm run test:unit |
Vitest unit tests (lib functions, consent store, Vue components) |
npm run test:unit:watch |
Vitest in watch mode during development |
npm run test:e2e |
Playwright (a11y + SEO/smoke). Requires npm run build first; astro preview serves the built dist/ |
npm run lint:reuse |
REUSE licence compliance check (requires pip install reuse once) |
node .ai/templates/generate-reveal-zip.mjs |
Regenerate public/downloads/offon-reveal-template.zip |
node .ai/templates/generate-pptx.mjs |
Regenerate public/downloads/offon-deck-template.pptx |
There is no content generator — routes and rendered prose come from the content collection at build time.
Always verify UI changes at mobile (375px), tablet (768px), and desktop (1280px) against the production build (npm run build && npm run preview), never the dev server.
src/
pages/ # File-based routes (.astro); dynamic routes use getStaticPaths()
index.astro, adventures/[id].astro, adventures/[id]/levels/[levelId].astro,
adventures/[id]/levels/[levelId]/solution.astro, challenges/[...tag].astro,
404.astro, the static pages, and _app.ts (Vue appEntrypoint)
layouts/Layout.astro # App shell: <head> (SEO, CSP, favicons, theme + GA4 bootstrap,
# JSON-LD), skip-nav, Navbar, <slot/>, Footer, ConsentBanner
components/ # *.astro (static, zero-JS) with inline scripts; *.vue reserved for future islands
content.config.ts # Content collection: Zod schema + custom loader + build-time markdown rendering
data/ # adventures/<id>/adventure.yaml + *-posts.json + leaderboard.json,
# solutions/<id>/<level>.ts, contributors.ts, types.ts, sponsors.ts, team.ts
lib/ # markdown-pipeline.mjs, adventure-derive.mjs, community-data.ts, solutions.ts,
# challenges.ts, difficulty.ts, markdown.ts, utils.ts, site.ts (constants), deadline.mjs
stores/ # nanostores: consent.ts ($consent + gtag injector)
styles/index.css # Tailwind @theme, component classes, light-mode overrides
assets/diagrams/ # Architecture SVGs (imported per-level via import.meta.glob)
e2e/
a11y.spec.ts # axe (dark/light/forced-colors) + touch targets + focus rings + 200% zoom
smoke.spec.ts # per-route title/canonical/OG/h1 + island hydration
scripts/ # refresh-*.mjs (community data), sync-adventure.mjs, set-discussion-url.mjs,
# generate-community-sitemap.mjs, check-docs.sh, lib/
public/ # copied verbatim to dist/ (fonts, favicons, brand, well-known, decks, og.png)
astro.config.mjs, tsconfig.json, playwright.config.ts, package.json
Adventures are authored as YAML at src/data/adventures/<id>/adventure.yaml and loaded by src/content.config.ts (Astro Content Collection):
- Source of truth: the YAML files. There are no generated
*.tsfiles to commit. - Validation: a Zod schema (
.strict()) runs vianpm run sync; invalid YAML fails the build. - Rendering: author markdown fields are converted to sanitised HTML in the collection loader (
src/lib/markdown-pipeline.mjs) at build time.getCollection('adventures')returns data with HTML fields ready to render viaset:html. - Discussion/leaderboard JSON is read at build time (
src/lib/community-data.ts) and rendered statically. Solutions are pre-built TS objects loaded viaimport.meta.glob. - Sync from challenges repo: the
sync-adventureGitHub Actions workflow writes the YAML + discussion stubs; routes appear automatically viagetStaticPaths().
| Path | Page | Purpose |
|---|---|---|
/ |
index.astro |
Home page |
/adventures/ |
adventures/index.astro |
Adventures list |
/adventures/:id/ |
adventures/[id].astro |
Adventure detail |
/adventures/:id/levels/:levelId/ |
adventures/[id]/levels/[levelId].astro |
Individual challenge |
/adventures/:id/levels/:levelId/solution/ |
.../solution.astro |
Solution walkthrough (post-deadline) |
/challenges/ and /challenges/:tag/ |
challenges/[...tag].astro |
All challenges; filter by technology tag |
/contribute/, /sponsors/, /about/, /handbook/ |
static .astro pages |
Contribute, sponsors, about, handbook |
/privacy/, /accessibility/, /brand/ |
static .astro pages |
Privacy (noindex), accessibility statement, brand guidelines |
/presentation-templates/ |
static .astro page |
Slide template downloads (noindex) |
/404/ |
404.astro |
404 page (dist/404.html, served by GitHub Pages) |
/docs, /docs/community-guide, /community-guide |
redirects → /handbook/ |
Legacy aliases (redirects in astro.config.mjs) |
The
/challengesfilter is a static.astrocomponent with a vanilla script. The full grid is server-rendered (works without JS); the script handles topic/difficulty filtering and syncs?topics/?difficultyto the URL. Adventure and challenge pages link tags to/challenges/:tag/.
- Per-page meta comes from the
<SEO>component (src/components/SEO.astro), fed byLayout.astroprops:<title>,<meta name="description">, canonical (${SITE_URL}${path}), Open Graph (og:title/description/type/url/image+ width/height/OG_IMAGE_ALT,og:site_name,og:localeen_GB), and Twitter card tags. Legal pages passnoindex. - Global head (
Layout.astro): charset, viewport,color-scheme, favicons, manifest, boththeme-colortags, CSP meta, and two JSON-LD blocks (WebSite+Organization). - Web manifest:
public/site.webmanifest(name, icons, theme/background colors, standalone display). - Sitemap/robots: Sitemap generated at build time by
src/pages/sitemap.xml.ts; andpublic/robots.txt.
Google Analytics 4 with Consent Mode v2 in gated-load mode: no data is sent to Google until the user clicks Accept; gtag.js is not loaded until then. Cross-domain measurement is configured in the GA4 admin UI. See the Analytics and Consent section of CLAUDE.md for the full design.
Constant (src/lib/site.ts) |
Purpose |
|---|---|
GA_MEASUREMENT_ID |
GA4 Measurement ID (used by the consent store's gtag injector). |
CONSENT_STORAGE_KEY |
localStorage key for the consent decision. |
CONSENT_EXPIRY_MS |
Stored consent expiry (180 days). |
Layout.astroships the minimal inline<head>bootstrap (dataLayer +gtagshim + all four signals denied; no gtag.js, nojs/config, no localStorage read).src/stores/consent.tsowns the state ($consentnanostore) and the gtag injector;src/components/ConsentBanner.astrois the static component. GA4 fires onepage_viewautomatically per load via thegtag('config', ...)call in the injector (nosend_page_view: false). On Decline/Reset,_ga*cookies are cleared.
- Push to
main→deploy.ymlbuildsdist/and deploys to GitHub Pages (https://offon.dev) viaJamesIves/github-pages-deploy-action. - Open a PR →
preview.ymlruns the content gate (astro sync), build, and the full Playwright suite, then deploys a preview at/pr-preview/pr-<n>/. - PRs touching adventure data →
validate-adventures.ymlvalidates the YAML (Zod viaastro sync), per-level discussion JSON, andADVENTURE_CATEGORIESregistration. - PRs adding components/utilities/constants/scripts/workflows →
validate-docs.ymlrequiresstyleguide.md/README.mdupdates.
Astro emits dist/404.html natively. PR preview builds set VITE_BASE_PATH=/pr-preview/pr-<n>/ (→ Astro base) so assets resolve under the sub-path; Layout.astro marks such builds noindex.
Adventures are authored in off-on-dev/open-source-challenges and pulled in via the Sync Adventure from Challenges Repo workflow, which writes the YAML + discussion stubs and opens a PR. See ADVENTURES.md for the full guide.
OffOn targets WCAG 2.2 Level AA across every page, in both light and dark mode. Automated axe scans run on every PR preview via e2e/a11y.spec.ts. The full statement is in ACCESSIBILITY.md; contributor rules are in the Accessibility section of CLAUDE.md.
ADVENTURES.md: syncing, reviewing, and updating adventures and levels.ACCESSIBILITY.md: public accessibility statement and how to report a barrier.PERFORMANCE.md: performance targets, image rules, font preloading, bundle size.styleguide.md: design system, color tokens, typography, component patterns.CLAUDE.md: contributor conventions, code quality, commit format, testing, accessibility.
- Source code: MIT
- Written content (docs, copy, curriculum data): CC BY 4.0
- Factual scraped data (community-leaders.json): CC0 1.0
- Bundled fonts (Inter, Syne, JetBrains Mono): SIL OFL 1.1
Per-file licensing is declared via the REUSE spec (REUSE.toml, LICENSES/).
The OffOn name and logo are reserved. See TRADEMARK.md.