This file is for anyone creating, syncing, or updating an adventure on offon.dev.
Adventures live in a separate repo (open-source-challenges) and are pulled into this site via the Sync Adventure GitHub Actions workflow.
Since the Astro migration there is no code generation step. Astro reads adventure.yaml directly through a Zod-validated content collection (src/content.config.ts) and renders the markdown prose to HTML at build time. Routes appear automatically via getStaticPaths(). The source of truth is the YAML; there are no *.generated.ts files.
Slug constraint: The
slugfield inadventure.yamlmust exactly match the adventure directory name undersrc/data/adventures/. The content loader asserts this at build time. If they diverge, the build fails with a clear message. When renaming a directory, update the YAMLslugfield to match (or vice versa).
off-on-dev/open-source-challenges offon.dev website repo
adventures/<id>/docs/
index.yaml ──── Sync Adventure workflow ────► src/data/adventures/<slug>/adventure.yaml
beginner.yaml src/data/adventures/<slug>/<level>-posts.json
intermediate.yaml
...
(build time) content collection ──► routes + rendered HTML (getStaticPaths)
Validate the YAML any time with npm run sync (runs the Zod schema; the build also fails on invalid content).
The authoritative schema is in src/content.config.ts (Zod .strict() — unknown fields fail the build). This table is derived from it; when the two diverge, the code wins.
| Field | Status | Type / Constraint | Notes |
|---|---|---|---|
slug |
Required | [a-z0-9][a-z0-9-]*[a-z0-9] |
Must match the directory name under src/data/adventures/. Build fails on mismatch. |
title or name |
One required | string | title for icon-based adventures; name for emoji-based. At least one must be set. |
icon |
Optional | Lucide icon name (e.g. Satellite) |
The sync workflow auto-registers the icon (imports, type union, emoji mapping) and writes icon: directly into adventure.yaml. Set explicitly in the challenges repo when the emoji alone is insufficient. |
emoji |
Optional | emoji character | Shown on the adventure card. The sync workflow maps it to a Lucide icon via the EMOJI_TO_ICON table; add the mapping there first if the emoji is new. |
month |
Required | MMM YYYY |
Three-letter uppercase abbreviation + four-digit year. Allowed: JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC. Validated by Zod regex; wrong format fails sync. |
tags |
Required | string[] (min 1) |
Technology/topic labels for the adventure as a whole. Seeds each level's topics at sync time and feeds the auto-generated meta_description fallback. Does not by itself create a filter chip or a /challenges/<tag>/ route: those come from level topics, so a tag no level teaches builds nothing. Add the tag to the relevant level's topics to make it filterable. |
meta_description |
Required | string, 160 chars recommended | Validated by the Zod schema; missing field fails npm run sync. Over 160 chars logs a warning and still builds, since search engines truncate rather than reject. Trim it before release. No em dashes; no - used as a dash. |
story |
Optional | markdown string | Short description shown on adventure cards and at the top of the adventure page. Card views strip HTML; set:html prose uses the rendered version. |
backstory |
Optional | string[] (markdown) |
Thematic narrative paragraphs rendered on the adventure page. |
overview |
Optional | string[] (markdown) |
Technical/content summary rendered on the adventure page. |
contributor |
Optional | object | name (required), url (optional URL), about (optional markdown), discourse_username (optional string -- Discourse username used for avatar resolution in community leaderboards). Copied from the challenges repo's docs/index.yaml when absent here; survives every re-sync once set. |
community_category_id |
Optional | integer | Discourse category ID. Survives every re-sync once set; position is kept directly after slug. |
rewards |
Optional | object | deadline (required inside; see format below), eligibility (markdown), tiers (array of {label, description}), ranking_note (markdown), ranking_rules_url (URL). |
upcoming_levels |
Optional | object[] | Coming-soon placeholders: {level?, name, difficulty}. Survives re-syncs for levels not yet in the challenges repo. |
levels |
Required | object[] (min 1) | See level fields below. |
rewards.deadline format and timezone handling. Preferred: ISO 8601 ("2026-07-01T23:59:00+01:00"). Also accepted: "Weekday, D Month YYYY at HH:MM TZ" (e.g. "Tuesday, 1 July 2026 at 23:59 CET"). Supported timezone abbreviations: CET (+01:00), CEST (+02:00), UTC (+00:00), GMT (+00:00). Unrecognised abbreviations are left as-is and logged as warnings. The string "TODO" is accepted and renders as an empty deadline (no gating).
Each entry in the levels array accepts the following fields.
| Field | Status | Type / Constraint | Notes |
|---|---|---|---|
level |
Required | string | Level identifier and URL segment: beginner, intermediate, or expert. |
name or title |
One required | string | Display name for the level. |
devcontainer |
Required | string | Devcontainer folder name in the challenges repo .devcontainer/ directory. |
topics |
Required | string[] |
Technologies covered by this level, and the source of truth for the /challenges/ filter chips and /challenges/<tag>/ routes. A tag page lists exactly the levels carrying that topic, so narrowing this list narrows what the level appears under. An empty [] is valid and falls back to the adventure tags for filtering; the sync seeds it from them, and inheriting is done by the sync workflow, not the schema. |
objective |
Required | string[] (markdown) |
Success criteria list shown to participants. |
toolbox |
Required | object[] | {name, description, url?} — tools available in the level environment. |
how_to_play |
Required | object[] | {id?, title, content} — ordered step-by-step instructions. |
emoji |
Optional | 🟢 / 🟡 / 🔴 |
Difficulty emoji. |
difficulty |
Optional | Beginner | Intermediate | Expert |
Inferred from emoji when absent. |
community_url |
Optional | URL string | Discourse thread URL for this level. Website-only; never overwritten by re-syncs. |
discussion_url |
Optional | URL string | Deprecated alias for community_url; preserved independently on re-sync. |
deadline |
Optional | ISO 8601 or human-readable | Level-specific deadline. Overrides rewards.deadline for solution page gating. Same format rules as rewards.deadline above. |
summary |
Optional | string | One-sentence summary for card views (plain text; not markdown). |
intro |
Optional | string[] (markdown) |
Opening paragraphs shown below the level heading. |
backstory |
Optional | string[] (markdown) |
Level-specific narrative paragraphs. |
architecture |
Optional | string[] (markdown) |
Architecture description paragraphs. |
architecture_diagram |
Optional | SVG filename | Filename only (e.g. echoes-beginner.svg) in src/assets/diagrams/. Add the SVG manually; the sync workflow strips this field from incoming content. Survives re-syncs once set manually. |
diagram_alt |
Optional | string | Alt text for the architecture diagram image. |
architecture_ascii |
Optional | string | ASCII art fallback for environments that cannot render SVG. |
audience |
Optional | string (markdown) | Description of who the level is aimed at. |
estimated_time |
Optional | string | Human-readable time estimate (e.g. "2–4 hours"). |
scenario |
Optional | string (markdown) | Scenario prose shown before the how-to-play steps. |
services |
Optional | object[] | Services exposed by the devcontainer: {name, port?, url?, credentials?, description, internal?}. Use port for a bare port number or url for a full URL (e.g. http://localhost:5173). An injected "Explore the UIs" step is generated automatically when at least one non-internal service has a port or url. |
helpful_links |
Optional | object[] | Reference links shown at the bottom of the level: {title, url, description?}. |
meta_description |
Optional | string, 160 chars recommended | Level-specific meta description. When absent, the generator builds one from name/title + intro[0] + difficulty + topics. Over 160 chars logs a warning and still builds. |
what_you_learn or learnings |
One required | string[] (min 1 when present) |
Learning objectives list. A Zod .refine() requires at least one of the two to be set; if both are absent the build fails. |
verification |
Required | object | {command, description} — the verification gate command and its description. |
codespaces_machine |
Optional | "4core" |
Machine size override for Codespaces. Only "4core" is accepted; other values fail the Zod schema. |
hook |
Optional | string | Verification hook command. |
contributor |
Optional | object | Person who built this specific level. Same subfields as the adventure contributor (name, url, about, discourse_username). Synced from that level's YAML in the challenges repo (e.g. docs/beginner.yaml), which sets it only when someone other than the designer built the level. A value already in the website YAML always wins, so re-crediting a level upstream needs the same hand-edit as re-crediting the designer. When omitted, the adventure designer is credited as the builder for this level. When set, takes precedence over the adventure designer for credit display on the level page and in community leaderboard sections. See note below. |
solved_count |
Optional | integer | Override for the displayed solved count. |
top_players |
Optional | object[] | System-populated leaderboard data: {username, count}. Set by the leaderboard refresh script; do not edit by hand. |
Level contributor: and the designer-as-builder rule. The credit rule is level.contributor ?? adventure.contributor, applied per level: a designer who builds two of three levels keeps credit for those two while a guest builder takes the third. Omitting contributor: from a level does not mean "no builder known" — it means the adventure designer built that level.
This makes absent contributor: ambiguous once real per-level builders exist alongside designer-built levels: the omission could mean "the designer built it" or "we have not yet recorded who built it." The PR checklist's "add contributor" step closes this gap in practice. If a level ever ships with a genuinely unknown builder, the fix is to allow contributor: null explicitly: update src/content.config.ts to accept z.nullable() on the level contributor field, treat explicit null as "no credit" in builderOfLevel in src/lib/adventure-credit.ts, and render nothing on the level page sidebar pill when the builder resolves to null. No data migration is needed — absent and null are both currently unset.
Go to Actions → Sync Adventure from Challenges Repo → Run workflow.
| Input | Required | Description |
|---|---|---|
adventure_url |
Yes | GitHub URL of the adventure folder — any branch works. Main: https://github.com/off-on-dev/open-source-challenges/tree/main/adventures/05-lex-imperfecta. PR branch: https://github.com/off-on-dev/open-source-challenges/tree/feat/my-branch/adventures/05-lex-imperfecta. |
levels |
No | Comma-separated level IDs to make live now (e.g. beginner or beginner,intermediate). Levels that exist in the challenges repo but are not listed here appear as "Coming Soon" placeholders. Leave blank to make all levels live. |
- Validates the URL points to
off-on-dev/open-source-challenges. - If a PR branch (
feat/adventure-<slug>) already exists, restoresadventure.yamlfrom that branch so any manual edits already made survive the re-sync. - Fetches
docs/index.yamland all level YAMLs from the challenges repo. - Writes
src/data/adventures/<slug>/adventure.yamland creates<level>-posts.jsonstubs for each new live level. - Registers the adventure's routes in
e2e/routes.tsso theroute-coverage.spec.tsdrift gate passes without a manual edit. The/challenges/<tag>/entries are derived from each live level'stopics, mirroringgetChallengeData()insrc/lib/challenges.ts. Adventuretagsare deliberately not used: a tag no level teaches builds no route, so registering one would list a route that never reachesdist/and trip the drift gate from the other side. - Validates the YAML with
astro sync(Zod content schema). There is no leaderboard registry to update:buildAdventureCategories()inscripts/refresh-leaderboard.mjsreadscommunity_category_idout of everyadventure.yamlat runtime, so setting that field (a PR checklist item below) is the whole registration step. Routes and sitemap entries are automatic viagetStaticPaths()andsrc/pages/sitemap.xml.ts.public/llms.txtis updated by hand as part of the PR checklist. - Opens (or updates) a PR on
feat/adventure-<slug>with a checklist of steps to complete before merging.
The PR body lists everything that needs to happen before merging. Here is each item explained.
contributor:
name: "Full Name"
url: "https://example.com"
about: "One sentence bio."
discourse_username: "their_forum_username"This lives in src/data/adventures/<slug>/adventure.yaml. The sync copies it from contributor: in the challenges repo's docs/index.yaml when the website YAML does not already have one, keeping only the four fields above -- the challenges repo owns its own schema, and passing an unknown field through would fail npm run sync against the strict content schema. Write it by hand only when the sync log warns that no contributor was found upstream.
url must be an absolute URL. A bare domain (ksick.dev) is rejected by the content schema, so the sync drops it, keeps the name, and warns. Add the https:// scheme upstream to restore the link.
If the challenges repo names no designer but a level YAML names a builder, the sync fails early with a message pointing at docs/index.yaml. Left to the content schema that failure lands in the next workflow step, before the PR branch exists, so there is nothing to hand-fix.
The url, about, and discourse_username fields are optional but recommended -- discourse_username enables avatar resolution in community leaderboards. A hand-edited block always wins over the upstream one and survives future re-syncs.
An adventure with no designer cannot have levels that name their own builder: creditIntegrityError in src/lib/adventure-credit.ts fails npm run sync on that combination.
The month: field defaults to the current month when first synced. Correct it if the adventure is planned for a future release. Format: MMM YYYY (e.g. JAN 2026). This field also survives re-syncs once set.
- Look up the Discourse category at
https://community.offon.dev/categories.json. - Find the category for this adventure and copy its
idinteger. - Add
community_category_id: <id>toadventure.yaml. - Run
npm run syncto validate the YAML against the content schema.
This field also survives future re-syncs once set.
Change rewards.deadline: from TODO to either an ISO 8601 datetime or the human-readable format used in the challenges repo:
# ISO 8601 (preferred for direct edits)
rewards.deadline: "2026-07-01T23:59:00+01:00"
# Human-readable (accepted; the generator converts it automatically)
rewards.deadline: "Tuesday, 1 July 2026 at 23:59 CET"See the rewards.deadline entry in the Field Reference above for the full format and supported timezone abbreviations.
Each level's topics: list is set by the sync workflow to the adventure's full tags list if the challenges repo does not set it explicitly. Refine it to the subset of technologies actually used in that level. This list is preserved on re-sync only if the challenges repo did not set it explicitly (see Re-syncing below).
Once you have created the Discourse thread for a level, use the Add Discussion URL to Level workflow (Actions tab → Add Discussion URL to Level → Run workflow).
| Input | Description |
|---|---|
adventure_id |
Adventure slug, e.g. lex-imperfecta |
level_id |
beginner, intermediate, or expert |
discussion_url |
Full Discourse thread URL, e.g. https://community.offon.dev/t/slug/1419 |
The workflow updates discussion_url in adventure.yaml, fetches the initial posts from Discourse into [level]-posts.json, and opens a PR. Run it once per level. If the thread is brand-new and has no posts yet, the PR will contain an empty discussionPosts array; the hourly refresh-community-data workflow will populate it once posts appear.
discussion_url in adventure.yaml is a website-only field. It is never in the challenges repo and survives every re-sync automatically.
If a level has an SVG architecture diagram, the sync strips the architecture_diagram: field because the SVG file must be added to src/assets/diagrams/ manually.
- Add the SVG file to
src/assets/diagrams/<filename>.svg. - Add
architecture_diagram: <filename>.svgback to the level inadventure.yaml.
Once set, architecture_diagram survives future re-syncs automatically.
node scripts/refresh-leaderboard.mjsRun this after community_category_id is set. It adds the adventure to the leaderboard data used on the site. Requires DISCOURSE_API_KEY and DISCOURSE_API_USERNAME in your environment or a .env file.
Devcontainer path verification is handled automatically by the sync-adventure workflow during import. If a devcontainer: value in the YAML does not match a folder in the challenges repo's .devcontainer directory, the workflow logs a warning in its output. If you see such a warning, update the devcontainer: value in adventure.yaml to match the correct folder name, and also fix the value upstream in the challenges repo so the next sync does not reintroduce the wrong value.
Add the new adventure entry to public/llms.txt by hand, following the format of the existing entries in the Adventures section. Add the adventure URL and a one-sentence description. Once levels are published, add per-level URLs as sub-bullets.
After the build passes, run the accessibility audit against any new or changed pages:
/a11y-auditTarget any new adventure or level detail pages. All severity-weighted findings must be resolved before merging.
npm run sync && npm run lint:reuse && npm run build && npm run test:e2eAll checks must pass before merging.
If the challenges repo is updated while your PR is still open, or you want to promote a "Coming Soon" level to live, just run the workflow again with the same (or updated) inputs. You do not need to close or recreate the PR.
- The workflow detects that
feat/adventure-<slug>already exists. - It restores
adventure.yamlfrom the PR branch so any manual edits already made are available to the sync script. - Fresh content is fetched from the challenges repo.
mergeLevelsmerges the incoming content with the existing levels, preserving certain fields (see table below).- The PR branch is force-pushed with the updated content.
- The open PR is updated in place (title, body).
| Field | Preserved | Notes |
|---|---|---|
contributor: (adventure) |
Always | Survives every re-sync once set. When unset, the sync copies it from docs/index.yaml in the challenges repo |
contributor: (level) |
Always | Survives every re-sync once set, even if the level's YAML upstream names someone else. When unset, the sync copies it from that level's YAML in the challenges repo. Re-crediting a level is a deliberate hand-edit |
community_category_id: (adventure) |
Always | Survives every re-sync once set; position is kept directly after slug |
month: (adventure) |
Always | Survives every re-sync once set |
discussion_url: / community_url: (level) |
Always | Website-only fields; never in the challenges repo. Both field aliases are preserved independently |
architecture_diagram: (level) |
Always | Stripped from incoming; preserved once added manually |
topics: (level) |
Only if challenges repo did not set them | If the challenges repo sets topics: explicitly, the upstream value wins |
upcoming_levels: entries for levels not yet upstream |
Always | Placeholders for levels not yet authored in the challenges repo survive re-syncs so "Coming Soon" cards are not dropped |
| All other level content | Never | Steps, objectives, toolbox, services, how_to_play, verification, etc. are always refreshed from the challenges repo |
When a new level is ready in the challenges repo after the first adventure PR has already merged:
- Run the workflow with the same
adventure_urland setlevelsto the new level ID (e.g.intermediate). - The workflow detects the adventure already exists in
mainand usesmode: update. - A new PR is opened on
feat/adventure-<slug>(the previous PR was merged, so there is no open PR to update). - Complete the checklist for the new level only. Adventure-level fields (
contributor,community_category_id,month) are already set inmainand are preserved automatically.
Note: For an existing adventure (
mode: update), specifying a level that does not yet exist in the challenges repo is an error. The workflow will fail and log the missing level IDs. Wait until the level YAML has been added to the challenges repo, then re-run the sync. This restriction does not apply to new adventures (mode: create), where missing levels produce "Coming Soon" placeholders as usual.
Solution walkthroughs live in src/data/solutions/<adventure-id>/<level-id>.ts and are committed to the repo.
The fastest way to add a solution is with the Claude Code skill:
/add-solutionPaste or attach the walkthrough content in any format — markdown, YAML, HTML, or plain text. The skill infers the adventure ID, level ID, and contributor name from the content where possible, confirms them with you, and then:
- Parses the input into structured steps (
SolutionBlock[]arrays with text, code, image, and callout blocks). - Downloads any referenced images and converts them to WebP at quality 85 using
cwebp. Images are saved topublic/solutions/<adventure-id>/. - Writes
src/data/solutions/<adventure-id>/<level-id>.tswith the full typedSolutionobject. - Runs
npm run buildto verify the output compiles cleanly. - Run
/a11y-auditagainst the new solution page to catch any accessibility issues before merging.
There is no solutions generator. src/lib/solutions.ts loads every src/data/solutions/<adventure-id>/<level-id>.ts via import.meta.glob at build time, and the solution route (/adventures/<id>/levels/<level>/solution/) is generated by getStaticPaths(). Just add the .ts file — no barrel or region markers to update. Add the route to the test lists in e2e/smoke.spec.ts and e2e/a11y.spec.ts.
Solutions are not visible on the site until the challenge deadline has passed. The solution page checks level.deadline (falling back to adventure.rewards.deadline) and renders a locked state with the deadline date until that moment arrives. Once the deadline passes, the page shows the full walkthrough automatically with no code change needed.
This means you can add a solution file to the repo at any point during the challenge period and it will not spoil anything for active participants.
src/data/solutions/<adventure-id>/<level-id>.ts ← authored TypeScript (commit this)
public/solutions/<adventure-id>/<level-id>-*.webp ← converted images (commit these)
| Workflow | Trigger | Purpose |
|---|---|---|
sync-adventure.yml |
Manual (workflow_dispatch) |
Sync adventure content from the challenges repo and open or update a PR |
add-discussion-url.yml |
Manual (workflow_dispatch) |
Set a Discourse thread URL for a level after it has been merged, and open a PR with updated YAML and initial posts |
validate-adventures.yml |
PR (when adventure files change) | Validate adventure YAML against the Zod content schema (astro sync), check every live level has its *-posts.json, verify the SKILL.md digest matches index.json |
deploy.yml |
Push to main |
Build and deploy to GitHub Pages at offon.dev |
preview.yml |
Open PR | Deploy a PR preview at /pr-preview/pr-<n>/ |
refresh-community-data.yml |
Hourly + manual | Refresh discussion posts, leaderboard data, and community leaders from Discourse, validate the result against the build's schemas, and commit only if it passes |
refresh-community-sitemap.yml |
Daily (05:00 UTC) + manual | Regenerate and commit the community Discourse sitemap |
These scripts run automatically on the hourly schedule but can also be run locally.
node scripts/refresh-discussions.mjs # Fetch discussion posts for each level (no credentials needed)
# The following two scripts require DISCOURSE_API_KEY and DISCOURSE_API_USERNAME in .env
node scripts/refresh-leaderboard.mjs # Fetch leaderboard data per adventure/level
node scripts/refresh-community-leaders.mjs # Fetch community leader data
# Validates everything the three scripts above wrote, using the schemas the build
# itself uses. The refresh workflow runs this before committing. Run from the repo root.
node scripts/validate-refreshed-data.mjsThese scripts are the only thing standing between a Discourse outage and the site serving stale community data forever, so they fail loudly rather than skipping.
| Script | Fails when |
|---|---|
refresh-discussions.mjs |
More than one topic errors in a run, or every attempted topic errors. Exactly one failure is tolerated so a single deleted thread cannot block every other topic's update, but it is logged as a warning naming the topic, so a failure that repeats every hour stays visible. The error lists each failing topic URL and its reason, which is what distinguishes one bad URL from Discourse being down. A topic counts as errored when any of its pagination chunks fails, not just the first request: a partial fetch would write a file that is structurally valid and missing replies, and the stored posts are the tail of the thread, so a dropped page silently removes activity and solver credit. Failing the topic leaves the previous file in place, stale but correct. |
refresh-leaderboard.mjs |
DISCOURSE_API_KEY is unset and CI is set; any Data Explorer query errors; or no adventure has a community_category_id. Without a key locally it still skips with exit 0. |
refresh-community-leaders.mjs |
DISCOURSE_API_KEY is unset and CI is set, or either Data Explorer query errors. Skips with exit 0 locally. |
validate-refreshed-data.mjs |
Any refreshed file fails the schema the build uses. Most often a Discourse section id that is not yet in SECTION_IDS (src/lib/community-leaders.ts); add it there and to SECTION_ICON_NAMES in CommunityLeaders.astro. |
The CI-only rule on the API key is deliberate: locally a missing key is a convenience, but in CI it means the secret was rotated or removed, and exiting 0 there would give a green run with data frozen indefinitely.
Create a .env file at the repo root for local use:
DISCOURSE_API_KEY=your_key_here
DISCOURSE_API_USERNAME=your_usernameThe .env file is gitignored. For CI, set DISCOURSE_API_KEY and DISCOURSE_API_USERNAME as repository secrets in Settings > Secrets and variables > Actions.
The
COMMUNITY_BASEconstant in each refresh script is a necessary duplicate ofCOMMUNITY_URLinsrc/lib/site.ts. The scripts run in Node outside the Vite build and cannot import fromsrc/. Always update all five places together if the community URL ever changes:refresh-discussions.mjs,refresh-leaderboard.mjs,refresh-community-leaders.mjs,generate-community-sitemap.mjs, andsrc/lib/site.ts.