Skip to content
Closed
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
19 changes: 12 additions & 7 deletions ADVENTURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ The authoritative schema is in [`src/content.config.ts`](src/content.config.ts)
| `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. |
| `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 Down Expand Up @@ -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 Down Expand Up @@ -123,7 +123,7 @@ Go to **Actions → Sync Adventure from Challenges Repo → Run workflow**.

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 +133,11 @@ 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.

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 +246,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
15 changes: 15 additions & 0 deletions e2e/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export const SMOKE_ROUTES: Record<string, string> = {
"/privacy/": "Privacy Policy - OffOn",
"/accessibility/": "Accessibility Statement - OffOn",
"/404/": "Page Not Found - OffOn",
// GENERATED:adventure-accessibility-nightmare-smoke
"/adventures/adventure-accessibility-nightmare/": "The Accessibility Nightmare - OffOn Adventures",
"/adventures/adventure-accessibility-nightmare/levels/beginner/": "The Initial Audit - The Accessibility Nightmare - OffOn",
// /GENERATED:adventure-accessibility-nightmare-smoke
};

export const A11Y_PAGES: string[] = [
Expand Down Expand Up @@ -86,6 +90,10 @@ export const A11Y_PAGES: string[] = [
"/adventures/the-ai-observatory/levels/expert/",
"/adventures/the-ai-observatory/levels/intermediate/",
"/404/",
// GENERATED:adventure-accessibility-nightmare-a11y
"/adventures/adventure-accessibility-nightmare/",
"/adventures/adventure-accessibility-nightmare/levels/beginner/",
// /GENERATED:adventure-accessibility-nightmare-a11y
];

/**
Expand Down Expand Up @@ -122,4 +130,11 @@ export const ROUTES_WITHOUT_FULL_COVERAGE: string[] = [
"/challenges/tdd/",
"/challenges/terraform/",
"/challenges/trivy/",
// GENERATED:adventure-accessibility-nightmare-challenges
"/challenges/axe-core/",
"/challenges/playwright/",
"/challenges/lighthouse/",
"/challenges/wcag-2-2/",
"/challenges/react/",
// /GENERATED:adventure-accessibility-nightmare-challenges
];
57 changes: 52 additions & 5 deletions scripts/sync-adventure.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,19 @@ function buildAdventureMetaDescription(indexData, activeLevels) {
return truncateAtWord(full, 160);
}

// The website's contributor schema is strict, so only the four fields it knows
// survive the trip from the challenges repo. Anything else in the upstream block
// would fail `astro sync` on a field the reviewer never wrote.
export function pickContributor(raw) {
if (!raw || typeof raw !== "object" || !raw.name) return null;
return {
name: raw.name,
...(raw.url && { url: raw.url }),
...(raw.about && { about: raw.about }),
...(raw.discourse_username && { discourse_username: raw.discourse_username }),
};
}

function transformStrings(value, fn) {
if (typeof value === "string") return fn(value);
if (Array.isArray(value)) return value.map((v) => transformStrings(v, fn));
Expand Down Expand Up @@ -268,14 +281,20 @@ function addToLucideIconsMap(content, iconName, kebab) {
return content.replace(re, m[1] + block + m[3]);
}

function buildLevel(raw, adventureTags, rewardsDeadline) {
export function buildLevel(raw, adventureTags, rewardsDeadline) {
// architecture_diagram is stripped here. After all levels are fetched, the sync attempts
// to pull the SVG from docs/diagrams/ in the challenges repo and re-adds the field if
// successful. If not found there, it must be added manually to src/assets/diagrams/.
const { architecture_diagram: _ignored, ...rest } = raw;
const cleaned = transformStrings(rest, stripCodeInLinks);
const { contributor: rawContributor, ...cleaned } = transformStrings(rest, stripCodeInLinks);
// A level `contributor` is the challenge builder, set upstream only when someone
// other than the adventure designer built this level. Filtered through the same
// picker as the designer: the website's contributor schema is strict, so a field
// the challenges repo carries but this site has no column for would fail sync.
const contributor = pickContributor(rawContributor);
return {
...cleaned,
...(contributor && { contributor }),
...(cleaned.deadline && { deadline: parseDeadline(cleaned.deadline, PRESERVE_TZ) }),
topics: cleaned.topics || deriveTopics(adventureTags),
verification: cleaned.verification || VERIFICATION_STUB,
Expand All @@ -295,14 +314,26 @@ function buildLevel(raw, adventureTags, rewardsDeadline) {
* injected by buildLevel, so manual edits are only preserved when
* the upstream did not intentionally change the field.
*/
function mergeLevels(existing, incoming, rawFetched) {
export function mergeLevels(existing, incoming, rawFetched) {
const levelMap = Object.fromEntries((existing || []).map((l) => [l.level, l]));
const rawMap = Object.fromEntries((rawFetched || []).map((l) => [l.level, l]));

for (const l of incoming) {
const prev = levelMap[l.level];
const raw = rawMap[l.level];

// The challenge builder, already credited in the website YAML. Held aside
// because it wins over the upstream value rather than only filling a gap.
const preservedContributor = pickContributor(prev?.contributor);
const upstreamContributor = pickContributor(raw?.contributor);
if (preservedContributor && upstreamContributor && upstreamContributor.name !== preservedContributor.name) {
console.warn(
` Level "${l.level}": keeping the builder already credited on the website ` +
`(${preservedContributor.name}) over the one named upstream (${upstreamContributor.name}). ` +
"Edit adventure.yaml by hand to re-credit this level."
);
}

levelMap[l.level] = {
...l,
// Preserve the discussion URL set by the add-discussion-url workflow.
Expand All @@ -320,6 +351,12 @@ function mergeLevels(existing, incoming, rawFetched) {
// intentional upstream changes come through. When the upstream did not set them (buildLevel
// derived them from adventure tags), preserve any manual refinements from the website.
...(!raw?.topics && prev?.topics && { topics: prev.topics }),
// contributor: the challenge builder. A builder already credited on the website
// always wins, matching the adventure designer: the sync fills this field in from
// the level YAML, it never overwrites it. Re-crediting a level to someone else is
// a hand-edit either way, and losing an existing credit silently misattributes a
// person's work, which is worse than a stale credit a reviewer can see and fix.
...(preservedContributor && { contributor: preservedContributor }),
};
}

Expand Down Expand Up @@ -471,6 +508,13 @@ async function main() {
// Used for level flags and e2e/routes.ts generation below.
const allLiveLevels = mergeLevels(existing?.levels, activeLevels, rawFetchedLevels);

const resolvedContributor = pickContributor(existing?.contributor) ?? pickContributor(indexData.contributor);
if (!existing?.contributor && resolvedContributor) {
console.log(` Contributor from docs/index.yaml: ${resolvedContributor.name}`);
} else if (!resolvedContributor) {
console.warn(" No contributor found in docs/index.yaml. Add a `contributor:` block to adventure.yaml before merging.");
}

// Build the combined adventure object using challenges repo field names.
// The generator accepts all aliases (name/title, emoji → icon, etc.).
const adventure = {
Expand All @@ -494,8 +538,11 @@ async function main() {
...(indexData.rewards.deadline && { deadline: parseDeadline(indexData.rewards.deadline, PRESERVE_TZ) }),
},
}),
// Preserve contributor set by a reviewer; omit otherwise (PR checklist item)
...(existing?.contributor && { contributor: existing.contributor }),
// A reviewer's hand-edited contributor wins; otherwise take the designer the
// challenges repo already names in docs/index.yaml. Levels may credit their own
// builder, and the content schema rejects that on an adventure with no designer,
// so an unsynced upstream contributor fails the build rather than just losing a pill.
...(resolvedContributor && { contributor: resolvedContributor }),
...(upcomingLevels.length > 0 && { upcoming_levels: upcomingLevels }),
levels: allLiveLevels,
};
Expand Down
26 changes: 22 additions & 4 deletions src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ const levelSchema = z
how_to_play: z.array(howToPlayStepSchema),
verification: verificationSchema,
helpful_links: z.array(helpfulLinkSchema).optional(),
meta_description: z.string().max(160).optional(),
// Length is warned about in renderLevel, not enforced: see warnIfMetaDescriptionLong.
meta_description: z.string().optional(),
solved_count: z.number().int().optional(),
top_players: z
.array(z.object({ username: z.string(), count: z.number().int() }).strict())
Expand All @@ -144,6 +145,20 @@ const levelSchema = z

// --- Resolvers ---

const META_DESCRIPTION_MAX = 160;

// Over-length meta descriptions are an SEO smell, not a content error: search
// engines truncate the tail and the page still renders correctly. Warn so the
// sync PR gets refined before release, but never fail `astro sync` (and with it
// the whole sync-adventure workflow) over a description a reviewer can trim.
function warnIfMetaDescriptionLong(value: string | undefined, where: string): void {
if (value && value.length > META_DESCRIPTION_MAX) {
console.warn(
`[content] ${where}: meta_description is ${value.length} chars, over the ${META_DESCRIPTION_MAX}-char SEO limit. Search engines will truncate it.`,
);
}
}

function requireEither(a: string | undefined | null, b: string | undefined | null, field: string): string {
const value = a ?? b;
if (value != null && value !== "") return value;
Expand Down Expand Up @@ -187,7 +202,8 @@ function assertDifficulty(
// AdventureLevel (from data/adventures/types.ts) is the single source of truth
// for the rendered level shape. renderLevel's return type is checked against it,
// so the two cannot drift silently.
async function renderLevel(level: z.infer<typeof levelSchema>): Promise<AdventureLevel> {
async function renderLevel(level: z.infer<typeof levelSchema>, slug: string): Promise<AdventureLevel> {
warnIfMetaDescriptionLong(level.meta_description, `${slug} → level "${level.level}"`);
const difficulty = level.difficulty ?? (level.emoji ? LEVEL_DIFFICULTY_BY_EMOJI[level.emoji as keyof typeof LEVEL_DIFFICULTY_BY_EMOJI] : undefined);
const learnings = level.learnings ?? level.what_you_learn ?? [];
const intro = level.intro ?? (level.summary ? [level.summary] : undefined);
Expand Down Expand Up @@ -345,7 +361,8 @@ const adventures = defineCollection({
tags: z.array(z.string()).min(1),
contributor: contributorSchema.optional(),
community_category_id: z.number().int().optional(),
meta_description: z.string().max(160),
// Length is warned about in the transform, not enforced: see warnIfMetaDescriptionLong.
meta_description: z.string(),
backstory: z.array(z.string()).optional(),
overview: z.array(z.string()).optional(),
rewards: rewardsSchema.optional(),
Expand All @@ -361,6 +378,7 @@ const adventures = defineCollection({
if (message) ctx.addIssue({ code: "custom", message, path: ["contributor"] });
})
.transform(async (data) => {
warnIfMetaDescriptionLong(data.meta_description, `adventure "${data.slug}"`);
const title = requireEither(data.title, data.name, "adventure title/name");
const story =
data.story ?? data.meta_description ?? (data.backstory && data.backstory.length > 0 ? data.backstory[0] : "");
Expand All @@ -370,7 +388,7 @@ const adventures = defineCollection({
mdToInline(story),
data.contributor?.about ? mdToInline(data.contributor.about) : Promise.resolve(null),
data.backstory ? mdToInlineArray(data.backstory) : Promise.resolve(null),
Promise.all(data.levels.map(renderLevel)),
Promise.all(data.levels.map((level) => renderLevel(level, data.slug))),
data.rewards ? renderRewards(data.rewards) : Promise.resolve(null),
]);

Expand Down
Loading
Loading