Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/sync-adventure.yml
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ jobs:

### Before merging

- [ ] Add \`contributor:\` block to \`src/data/adventures/${SLUG}/adventure.yaml\`
- [ ] Check the \`contributor:\` block in \`src/data/adventures/${SLUG}/adventure.yaml\`. It is synced from
\`docs/index.yaml\` in the challenges repo; add it by hand only if the sync log says none was found.
\`\`\`yaml
contributor:
name: "Full Name"
Expand Down
32 changes: 21 additions & 11 deletions ADVENTURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,12 @@ The authoritative schema is in [`src/content.config.ts`](src/content.config.ts)
| `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 shown as filter chips. Used when the auto-generated `meta_description` falls back to name + backstory. |
| `meta_description` | **Required** | string, max 160 chars | Validated by the Zod schema; missing field fails `npm run sync`. Max 160 chars. No em dashes; no ` - ` used as a dash. |
| `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). Survives every re-sync once set. |
| `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. |
Expand All @@ -61,7 +61,7 @@ Each entry in the `levels` array accepts the following fields.
| `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. An empty `[]` is valid and stays empty; inheriting adventure `tags` is done by the sync workflow, not the schema. |
| `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. |
Expand All @@ -82,12 +82,12 @@ Each entry in the `levels` array accepts the following fields.
| `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, max 160 chars | Level-specific meta description. When absent, the generator builds one from `name`/`title` + `intro[0]` + difficulty + topics. |
| `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`). **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. |
| `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. |

Expand All @@ -114,16 +114,17 @@ Go to **Actions → Sync Adventure from Challenges Repo → Run workflow**.
2. If a PR branch (`feat/adventure-<slug>`) already exists, restores `adventure.yaml` from that branch so any manual edits already made survive the re-sync.
3. Fetches `docs/index.yaml` and all level YAMLs from the challenges repo.
4. Writes `src/data/adventures/<slug>/adventure.yaml` and creates `<level>-posts.json` stubs for each new live level.
5. Validates the YAML with `astro sync` (Zod content schema). There is no leaderboard registry to update: `buildAdventureCategories()` in `scripts/refresh-leaderboard.mjs` reads `community_category_id` out of every `adventure.yaml` at runtime, so setting that field (a PR checklist item below) is the whole registration step. Routes and sitemap entries are automatic via `getStaticPaths()` and `src/pages/sitemap.xml.ts`. `public/llms.txt` is updated by hand as part of the PR checklist.
6. Opens (or updates) a PR on `feat/adventure-<slug>` with a checklist of steps to complete before merging.
5. Registers the adventure's routes in `e2e/routes.ts` so the `route-coverage.spec.ts` drift gate passes without a manual edit. The `/challenges/<tag>/` entries are derived from each live level's `topics`, mirroring `getChallengeData()` in `src/lib/challenges.ts`. Adventure `tags` are deliberately not used: a tag no level teaches builds no route, so registering one would list a route that never reaches `dist/` and trip the drift gate from the other side.
6. Validates the YAML with `astro sync` (Zod content schema). There is no leaderboard registry to update: `buildAdventureCategories()` in `scripts/refresh-leaderboard.mjs` reads `community_category_id` out of every `adventure.yaml` at runtime, so setting that field (a PR checklist item below) is the whole registration step. Routes and sitemap entries are automatic via `getStaticPaths()` and `src/pages/sitemap.xml.ts`. `public/llms.txt` is updated by hand as part of the PR checklist.
7. Opens (or updates) a PR on `feat/adventure-<slug>` with a checklist of steps to complete before merging.

---

## Completing the PR Checklist

The PR body lists everything that needs to happen before merging. Here is each item explained.

### Add contributor block
### Check the contributor block

```yaml
contributor:
Expand All @@ -133,7 +134,15 @@ contributor:
discourse_username: "their_forum_username"
```

Add this to `src/data/adventures/<slug>/adventure.yaml`. The `url`, `about`, and `discourse_username` fields are optional but recommended -- `discourse_username` enables avatar resolution in community leaderboards. Once set, this block survives future re-syncs automatically.
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.

### Confirm month

Expand Down Expand Up @@ -242,7 +251,8 @@ If the challenges repo is updated while your PR is still open, or you want to pr

| Field | Preserved | Notes |
| --- | --- | --- |
| `contributor:` (adventure) | Always | Survives every re-sync once set |
| `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 |
Expand Down
3 changes: 1 addition & 2 deletions e2e/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export const A11Y_PAGES: string[] = [
* gate in route-coverage.spec.ts.
*/
export const ROUTES_WITHOUT_FULL_COVERAGE: string[] = [
// 24 remaining /challenges/<tag>/ routes. They are the same page component with
// 23 remaining /challenges/<tag>/ routes. They are the same page component with
// a different filter seed, so /challenges/opentelemetry/ is the representative.
// Listed explicitly rather than pattern-matched so a new tag still has to be
// acknowledged here.
Expand All @@ -120,6 +120,5 @@ export const ROUTES_WITHOUT_FULL_COVERAGE: string[] = [
"/challenges/python/",
"/challenges/spring-boot/",
"/challenges/tdd/",
"/challenges/terraform/",
"/challenges/trivy/",
];
Loading
Loading