diff --git a/.github/workflows/sync-adventure.yml b/.github/workflows/sync-adventure.yml new file mode 100644 index 00000000..4429d203 --- /dev/null +++ b/.github/workflows/sync-adventure.yml @@ -0,0 +1,126 @@ +name: Sync Adventure from Challenges Repo + +on: + workflow_dispatch: + inputs: + adventure_url: + description: > + URL of the adventure folder in the challenges repo + (e.g. https://github.com/off-on-dev/open-source-challenges/tree/main/adventures/05-lex-imperfecta) + required: true + type: string + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Install dependencies + run: npm ci + + - name: Validate URL + env: + ADVENTURE_URL: ${{ inputs.adventure_url }} + run: | + if ! echo "$ADVENTURE_URL" | grep -qE '^https://github\.com/[^/]+/[^/]+/tree/[^/]+/adventures/'; then + echo "Error: URL must point to an adventure folder under adventures/ in the challenges repo" + echo "Example: https://github.com/off-on-dev/open-source-challenges/tree/main/adventures/05-lex-imperfecta" + exit 1 + fi + + - name: Fetch and transform adventure YAML + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ADVENTURE_URL: ${{ inputs.adventure_url }} + run: node scripts/sync-adventure.mjs + + - name: Validate generated YAML + run: node scripts/generate-adventures.mjs --validate-only + + - name: Regenerate TypeScript + run: node scripts/generate-adventures.mjs + + - name: Create pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + SLUG=$(cat /tmp/adventure-slug) + NAME=$(cat /tmp/adventure-name) + LEVELS=$(cat /tmp/adventure-levels) + MODE=$(cat /tmp/adventure-mode) + BRANCH="feat/adventure-${SLUG}" + SOURCE_URL="${{ inputs.adventure_url }}" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH" + git add src/data/adventures/${SLUG}/ + git commit -m "feat: sync adventure ${SLUG} from challenges repo (${MODE})" + + git push origin --delete "$BRANCH" 2>/dev/null || true + git push origin "$BRANCH" + + if [ "$MODE" = "update" ]; then + PR_TITLE="feat(adventure): add levels to ${NAME} — ${LEVELS}" + else + PR_TITLE="feat(adventure): add ${NAME}" + fi + + { + printf '## %s: %s\n\n' "$MODE" "$NAME" + printf 'Auto-synced from the challenges repo.\n\n' + printf '**Source:** %s\n\n' "$SOURCE_URL" + printf '**Levels in this PR:** `%s`\n\n' "$LEVELS" + printf '---\n\n' + printf '### Before merging\n\n' + printf -- '- [ ] Add `contributor:` block to `src/data/adventures/%s/adventure.yaml`\n' "$SLUG" + printf -- ' ```yaml\n' + printf -- ' contributor:\n' + printf -- ' name: "Full Name"\n' + printf -- ' url: "https://example.com"\n' + printf -- ' about: "One sentence bio."\n' + printf -- ' ```\n' + printf -- '- [ ] Confirm `month:` is correct for the planned release\n' + printf -- '- [ ] Update `rewards.deadline:` from `TODO` to ISO 8601 (e.g. `2026-07-01T23:59:00+01:00`)\n' + printf -- '- [ ] Review `topics:` on each level — auto-set to all adventure tags, refine to level-specific subset if needed\n' + printf -- '- [ ] Update `community_url:` in each level once the Discourse threads are created\n' + printf -- '- [ ] Update `discussionUrl` in each `*-posts.json` stub\n\n' + printf '### Routes and config (manual steps)\n\n' + printf -- '- [ ] Add adventure and level routes to `src/routes.ts`\n' + printf -- '- [ ] Add URLs to `public/sitemap.xml`\n' + printf -- '- [ ] Add URLs to the `prerender` array in `react-router.config.ts`\n' + printf -- '- [ ] Add adventure to `ADVENTURE_CATEGORIES` in `scripts/refresh-leaderboard.mjs`, then run `node scripts/refresh-leaderboard.mjs`\n' + printf -- '- [ ] Run `node scripts/refresh-discussions.mjs`\n' + printf -- '- [ ] Add each level URL to `ROUTES` in `e2e/smoke.spec.ts` and `src/test/seo.test.ts`\n' + printf -- '- [ ] Add each level to `pages` array in `src/test/prerender.test.ts` with expected ``\n' + printf -- '- [ ] Update routes table in `README.md`\n\n' + printf '### Checks\n\n' + printf '```sh\n' + printf 'npm run lint && npm test && npm run build && npm run test:e2e\n' + printf '```\n' + } > /tmp/pr-body.md + + EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number // empty') + if [ -n "$EXISTING_PR" ]; then + echo "Updating existing PR #${EXISTING_PR}" + gh pr edit "$EXISTING_PR" --title "$PR_TITLE" --body-file /tmp/pr-body.md + else + gh pr create \ + --title "$PR_TITLE" \ + --body-file /tmp/pr-body.md \ + --head "$BRANCH" \ + --base main + fi diff --git a/schemas/adventure.schema.json b/schemas/adventure.schema.json index 35d8e34e..ca6f7824 100644 --- a/schemas/adventure.schema.json +++ b/schemas/adventure.schema.json @@ -4,7 +4,7 @@ "title": "Adventure", "description": "Schema for an OffOn adventure YAML file.", "type": "object", - "required": ["slug", "name", "month", "story", "technologies", "levels"], + "required": ["slug", "title", "month", "tags", "levels"], "additionalProperties": false, "properties": { "slug": { @@ -12,13 +12,21 @@ "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$", "description": "Kebab-case URL slug. Must match the folder name." }, + "title": { + "type": "string", + "description": "Display title for the adventure." + }, "name": { "type": "string", - "description": "Display name for the adventure." + "description": "Alias for title. Use title or name, not both." + }, + "emoji": { + "type": "string", + "description": "Emoji representing this adventure (e.g. '⚖️'). The generator maps it to a Lucide icon automatically. Use this OR icon, not both." }, "icon": { "type": "string", - "description": "Lucide React icon name representing this adventure (e.g. 'FlaskConical'). Used as a visual identity marker in the adventure header." + "description": "Lucide React icon name (e.g. 'FlaskConical'). Overrides emoji-derived icon when both are present." }, "month": { "type": "string", @@ -29,11 +37,15 @@ "type": "string", "description": "One-paragraph summary of the adventure." }, - "technologies": { + "story": { + "type": "string", + "description": "One-paragraph card summary. Omit to derive from backstory[0]." + }, + "tags": { "type": "array", "items": { "type": "string" }, "minItems": 1, - "description": "Technology names used in this adventure (e.g. ['OpenFeature', 'Spring Boot'])." + "description": "Technology tags (e.g. ['OpenFeature', 'Spring Boot'])." }, "contributor": { "$ref": "#/$defs/contributor" @@ -79,7 +91,7 @@ "required": ["deadline", "tiers"], "additionalProperties": false, "properties": { - "deadline": { "type": "string", "description": "ISO 8601 datetime (e.g. '2026-05-26T23:59:00+01:00')." }, + "deadline": { "type": "string", "description": "ISO 8601 datetime (e.g. '2026-05-26T23:59:00+01:00'). Use 'TODO' as a placeholder — the generator will warn and skip formatting." }, "eligibility": { "type": "string", "description": "Omit to use the standard eligibility text." }, "tiers": { "type": "array", @@ -111,14 +123,20 @@ }, "level": { "type": "object", - "required": ["id", "name", "difficulty", "topics", "learnings", "devcontainer_path", "discussion_url", "intro", "objective", "toolbox", "how_to_play", "verification"], + "required": ["level", "name", "topics", "devcontainer", "objective", "toolbox", "how_to_play", "verification"], "additionalProperties": false, "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, + "level": { "type": "string", "description": "Level identifier: beginner, intermediate, or expert." }, + "name": { "type": "string", "description": "Display name for the level." }, + "title": { "type": "string", "description": "Alias for name. Use name or title, not both." }, + "emoji": { + "type": "string", + "description": "Difficulty emoji: 🟢 Beginner, 🟡 Intermediate, 🔴 Expert. Use this OR difficulty, not both." + }, "difficulty": { "type": "string", - "enum": ["Beginner", "Intermediate", "Expert"] + "enum": ["Beginner", "Intermediate", "Expert"], + "description": "Explicit difficulty. Omit when using emoji." }, "topics": { "type": "array", @@ -127,24 +145,40 @@ "learnings": { "type": "array", "items": { "type": "string" }, - "minItems": 1 + "minItems": 1, + "description": "Key learnings. Use this OR what_you_learn." }, - "devcontainer_path": { + "what_you_learn": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "description": "Alias for learnings." + }, + "devcontainer": { "type": "string", - "description": "Path to the devcontainer.json relative to the challenges repo root (e.g. '.devcontainer/04-blind-by-design_01-beginner/devcontainer.json'). The codegen script builds the full Codespaces URL from this." + "description": "Short devcontainer name (e.g. 'lex-imperfecta_beginner'). Generator expands to '.devcontainer/{value}/devcontainer.json'." }, "discussion_url": { "type": "string", "description": "Full Discourse topic URL or a path relative to COMMUNITY_URL (e.g. '/t/topic-slug/1419')." }, + "community_url": { + "type": "string", + "description": "Alias for discussion_url." + }, "deadline": { "type": "string", "description": "Submission deadline for this level as an ISO 8601 string (e.g. '2025-12-10T09:00:00+01:00'). Only shown when rewards are active." }, "hook": { "type": "string" }, + "summary": { + "type": "string", + "description": "Alias for intro. Single string; generator wraps in array." + }, "intro": { "type": "array", - "items": { "type": "string" } + "items": { "type": "string" }, + "description": "Brief intro paragraph(s). Use this OR summary." }, "backstory": { "type": "array", @@ -205,6 +239,7 @@ "required": ["title", "content"], "additionalProperties": false, "properties": { + "id": { "type": "string", "description": "Optional step identifier from the challenges repo. Ignored by the generator." }, "title": { "type": "string" }, "content": { "type": "string", "description": "Markdown content. Can contain code blocks." } } diff --git a/scripts/generate-adventures.mjs b/scripts/generate-adventures.mjs index 5d532f05..a7ae100f 100644 --- a/scripts/generate-adventures.mjs +++ b/scripts/generate-adventures.mjs @@ -36,6 +36,22 @@ const SCHEMA_PATH = resolve(ROOT, "schemas/adventure.schema.json"); const validateOnly = process.argv.includes("--validate-only"); +// Maps emoji shorthand to Lucide React icon names used in AdventureDetail. +const EMOJI_ICON_MAP = { + "🧪": "FlaskConical", + "🔭": "Telescope", + "☁️": "Cloud", + "🛰️": "Satellite", + "⚖️": "Scale", +}; + +// Maps difficulty-indicator emoji to the canonical difficulty string. +const LEVEL_EMOJI_DIFFICULTY = { + "🟢": "Beginner", + "🟡": "Intermediate", + "🔴": "Expert", +}; + // Constant rewards fields shared by all adventures. Omit from YAML to use these defaults. const DEFAULT_REWARDS_ELIGIBILITY = "Complete all levels and post your solution in the community before the deadline to be eligible."; @@ -105,6 +121,11 @@ function formatStringArray(arr, indent) { return `[\n${items}\n${indent}]`; } +/** Returns true if str is a valid ISO 8601 datetime with UTC offset. */ +function isValidISODeadline(str) { + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/.test(str); +} + // --- YAML Discovery --- function findAdventureYamls() { @@ -127,11 +148,16 @@ function validateAdventure(data, id) { const errors = []; if (!data.slug) errors.push("Missing required field: slug"); else if (data.slug !== id) errors.push(`slug "${data.slug}" does not match folder name "${id}"`); - if (!data.name) errors.push("Missing required field: name"); + if (!data.title && !data.name) errors.push("Missing required field: title (or name)"); if (!data.month) errors.push("Missing required field: month"); - if (!data.story) errors.push("Missing required field: story"); - if (!data.technologies || !Array.isArray(data.technologies) || data.technologies.length === 0) { - errors.push("Missing or empty required field: technologies"); + if (!data.story && (!data.backstory || data.backstory.length === 0)) { + errors.push("Missing required field: story (or provide backstory to derive it from)"); + } + if (!data.tags || !Array.isArray(data.tags) || data.tags.length === 0) { + errors.push("Missing or empty required field: tags"); + } + if (data.rewards && data.rewards.deadline && !isValidISODeadline(data.rewards.deadline)) { + warn(`${id}: rewards.deadline "${data.rewards.deadline}" is not ISO 8601 — update before publishing (e.g. "2026-05-26T23:59:00+01:00")`); } if (!data.levels || !Array.isArray(data.levels) || data.levels.length === 0) { errors.push("Missing or empty required field: levels"); @@ -139,19 +165,28 @@ function validateAdventure(data, id) { for (let i = 0; i < data.levels.length; i++) { const level = data.levels[i]; const prefix = `levels[${i}]`; - if (!level.id) errors.push(`${prefix}: Missing id`); - if (!level.name) errors.push(`${prefix}: Missing name`); - if (!level.difficulty) errors.push(`${prefix}: Missing difficulty`); - if (!["Beginner", "Intermediate", "Expert"].includes(level.difficulty)) { - errors.push(`${prefix}: Invalid difficulty "${level.difficulty}"`); + if (!level.level) errors.push(`${prefix}: Missing level`); + if (!level.name && !level.title) errors.push(`${prefix}: Missing name (or title)`); + if (level.deadline && !isValidISODeadline(level.deadline)) { + warn(`${id} ${prefix}: deadline "${level.deadline}" is not ISO 8601 — update before publishing`); + } + const difficulty = level.difficulty || LEVEL_EMOJI_DIFFICULTY[level.emoji]; + if (!difficulty) errors.push(`${prefix}: Missing difficulty (or emoji 🟢/🟡/🔴)`); + else if (!["Beginner", "Intermediate", "Expert"].includes(difficulty)) { + errors.push(`${prefix}: Invalid difficulty "${difficulty}"`); } if (!level.topics || level.topics.length === 0) errors.push(`${prefix}: Missing topics`); - if (!level.learnings || level.learnings.length === 0) { - errors.push(`${prefix}: Missing learnings`); + if (!level.learnings && !level.what_you_learn) { + errors.push(`${prefix}: Missing learnings (or what_you_learn)`); } - if (!level.devcontainer_path) errors.push(`${prefix}: Missing devcontainer_path`); - if (!level.discussion_url) errors.push(`${prefix}: Missing discussion_url`); - if (!level.intro || level.intro.length === 0) errors.push(`${prefix}: Missing intro`); + if (!level.devcontainer) errors.push(`${prefix}: Missing devcontainer`); + const discussionUrl = level.discussion_url ?? level.community_url; + if (discussionUrl === undefined || discussionUrl === null) { + errors.push(`${prefix}: Missing discussion_url (or community_url)`); + } else if (discussionUrl === "") { + warn(`${id} ${prefix}: discussion_url/community_url is empty — update with Discourse thread URL before publishing`); + } + if (!level.intro && !level.summary) errors.push(`${prefix}: Missing intro (or summary)`); if (!level.objective || level.objective.length === 0) errors.push(`${prefix}: Missing objective`); if (!level.toolbox || level.toolbox.length === 0) errors.push(`${prefix}: Missing toolbox`); if (!level.how_to_play || level.how_to_play.length === 0) errors.push(`${prefix}: Missing how_to_play`); @@ -168,10 +203,15 @@ function generateLevelCode(level, adventureId, indent) { const i = indent; const i2 = indent + " "; + const levelDifficulty = level.difficulty || LEVEL_EMOJI_DIFFICULTY[level.emoji]; + const levelLearnings = level.learnings || level.what_you_learn; + const levelIntro = level.intro || (level.summary ? [level.summary] : undefined); + const levelDiscussionUrl = level.discussion_url || level.community_url || ""; + lines.push(`${i}{`); - lines.push(`${i2}id: "${level.id}",`); - lines.push(`${i2}name: "${escapeDoubleQuoted(level.name)}",`); - lines.push(`${i2}difficulty: "${level.difficulty}",`); + lines.push(`${i2}id: "${escapeDoubleQuoted(level.level)}",`); + lines.push(`${i2}name: "${escapeDoubleQuoted(level.name || level.title)}",`); + lines.push(`${i2}difficulty: "${levelDifficulty}",`); if (level.topics) { lines.push(`${i2}topics: [${level.topics.map((t) => `"${escapeDoubleQuoted(t)}"`).join(", ")}],`); @@ -180,24 +220,26 @@ function generateLevelCode(level, adventureId, indent) { lines.push(`${i2}audience: ${formatString(level.audience)},`); } - lines.push(`${i2}learnings: ${formatStringArray(level.learnings, i2)},`); + lines.push(`${i2}learnings: ${formatStringArray(levelLearnings, i2)},`); - // Build codespacesUrl from devcontainer_path - const encodedPath = encodeURIComponent(level.devcontainer_path).replace(/%2F/g, "%2F"); + // Build codespacesUrl from devcontainer short name + const fullDevcontainerPath = `.devcontainer/${level.devcontainer}/devcontainer.json`; + const encodedPath = encodeURIComponent(fullDevcontainerPath).replace(/%2F/g, "%2F"); lines.push(`${i2}codespacesUrl: \`\${CODESPACES_BASE}?devcontainer_path=${encodedPath}&quickstart=1\`,`); - // Build discussionUrl - if (level.discussion_url.startsWith("http")) { - lines.push(`${i2}discussionUrl: "${escapeDoubleQuoted(level.discussion_url)}",`); - } else { - // Relative path: prepend COMMUNITY_URL - const path = level.discussion_url.startsWith("/") ? level.discussion_url : `/${level.discussion_url}`; + // Build discussionUrl — always output (empty string is valid placeholder for new adventures) + if (levelDiscussionUrl && levelDiscussionUrl.startsWith("http")) { + lines.push(`${i2}discussionUrl: "${escapeDoubleQuoted(levelDiscussionUrl)}",`); + } else if (levelDiscussionUrl) { + const path = levelDiscussionUrl.startsWith("/") ? levelDiscussionUrl : `/${levelDiscussionUrl}`; lines.push(`${i2}discussionUrl: \`\${COMMUNITY_URL}${path}\`,`); + } else { + lines.push(`${i2}discussionUrl: "",`); } if (level.deadline) lines.push(`${i2}deadline: "${escapeDoubleQuoted(level.deadline)}",`); if (level.hook) lines.push(`${i2}hook: ${formatString(level.hook)},`); - if (level.intro) lines.push(`${i2}intro: ${formatStringArray(level.intro, i2)},`); + if (levelIntro) lines.push(`${i2}intro: ${formatStringArray(levelIntro, i2)},`); if (level.backstory) lines.push(`${i2}backstory: ${formatStringArray(level.backstory, i2)},`); if (level.objective) lines.push(`${i2}objective: ${formatStringArray(level.objective, i2)},`); if (level.scenario) lines.push(`${i2}scenario: ${formatString(level.scenario)},`); @@ -304,13 +346,17 @@ function generateAdventureTs(data) { lines.push(`import type { Adventure } from "./types";`); lines.push(``); + const adventureTitle = data.title || data.name; + const adventureStory = data.story || (data.backstory && data.backstory.length > 0 ? data.backstory[0] : ""); + const adventureIcon = data.icon || (data.emoji ? EMOJI_ICON_MAP[data.emoji] : undefined); + lines.push(`export const ${constName}: Adventure = {`); lines.push(` id: "${data.slug}",`); - lines.push(` title: "${escapeDoubleQuoted(data.name)}",`); - if (data.icon) lines.push(` icon: "${escapeDoubleQuoted(data.icon)}",`); + lines.push(` title: "${escapeDoubleQuoted(adventureTitle)}",`); + if (adventureIcon) lines.push(` icon: "${escapeDoubleQuoted(adventureIcon)}",`); lines.push(` month: "${data.month}",`); - lines.push(` story: ${formatString(data.story)},`); - lines.push(` tags: [${data.technologies.map((t) => `"${escapeDoubleQuoted(t)}"`).join(", ")}],`); + lines.push(` story: ${formatString(adventureStory)},`); + lines.push(` tags: [${data.tags.map((t) => `"${escapeDoubleQuoted(t)}"`).join(", ")}],`); if (data.contributor) { lines.push(` contributor: {`); @@ -386,24 +432,28 @@ function generateSummariesTs(adventures) { for (const data of adventures) { lines.push(` {`); + const summaryTitle = data.title || data.name; + const summaryStory = data.story || (data.backstory && data.backstory.length > 0 ? data.backstory[0] : ""); lines.push(` id: "${data.slug}",`); - lines.push(` title: "${escapeDoubleQuoted(data.name)}",`); + lines.push(` title: "${escapeDoubleQuoted(summaryTitle)}",`); lines.push(` month: "${data.month}",`); - lines.push(` story: ${formatString(data.story)},`); - lines.push(` tags: [${data.technologies.map((t) => `"${escapeDoubleQuoted(t)}"`).join(", ")}],`); + lines.push(` story: ${formatString(summaryStory)},`); + lines.push(` tags: [${data.tags.map((t) => `"${escapeDoubleQuoted(t)}"`).join(", ")}],`); if (data.contributor) { lines.push(` contributor: { name: "${escapeDoubleQuoted(data.contributor.name)}" },`); } lines.push(` levels: [`); for (const level of data.levels) { lines.push(` {`); - lines.push(` id: "${level.id}",`); - lines.push(` name: "${escapeDoubleQuoted(level.name)}",`); - lines.push(` difficulty: "${level.difficulty}",`); + const summaryDifficulty = level.difficulty || LEVEL_EMOJI_DIFFICULTY[level.emoji]; + const summaryLearnings = level.learnings || level.what_you_learn; + lines.push(` id: "${escapeDoubleQuoted(level.level)}",`); + lines.push(` name: "${escapeDoubleQuoted(level.name || level.title)}",`); + lines.push(` difficulty: "${summaryDifficulty}",`); if (level.topics && level.topics.length > 0) { lines.push(` topics: [${level.topics.map((t) => `"${escapeDoubleQuoted(t)}"`).join(", ")}],`); } - lines.push(` learnings: ${formatStringArray(level.learnings, " ")},`); + lines.push(` learnings: ${formatStringArray(summaryLearnings, " ")},`); lines.push(` },`); } lines.push(` ],`); diff --git a/scripts/sync-adventure.mjs b/scripts/sync-adventure.mjs new file mode 100644 index 00000000..bca8b921 --- /dev/null +++ b/scripts/sync-adventure.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node + +/** + * Fetches adventure YAML files from the challenges repo and produces a + * website-compatible adventure.yaml, discussion JSON stubs, and regenerated TS. + * + * Environment variables: + * ADVENTURE_URL - GitHub URL of the adventure folder in the challenges repo + * e.g. https://github.com/off-on-dev/open-source-challenges/tree/main/adventures/05-lex-imperfecta + * + * Outputs to /tmp/: + * adventure-slug - slug of the created/updated adventure + * adventure-name - display name + * adventure-levels - comma-separated level ids + * adventure-mode - "create" or "update" + */ + +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, ".."); +const ADVENTURES_DIR = resolve(ROOT, "src/data/adventures"); + +const VERIFICATION_STUB = { + command: "./verify.sh", + description: + "Once you think you've solved the challenge, run the verification script. " + + "If it fails it will tell you which checks didn't pass. " + + "If it passes, it generates a Certificate of Completion you can paste into the discussion.", +}; + +const LEVEL_ORDER = { beginner: 0, intermediate: 1, expert: 2 }; + +function fail(msg) { + console.error(`\x1b[31mError:\x1b[0m ${msg}`); + process.exit(1); +} + +function currentMonth() { + const d = new Date(); + return d.toLocaleString("en-GB", { month: "short" }).toUpperCase() + " " + d.getFullYear(); +} + +function parseAdventureUrl(url) { + const m = url.match(/github\.com\/([^/]+\/[^/]+)\/(?:tree|blob)\/[^/]+\/(.+)/); + if (!m) fail(`Cannot parse GitHub URL: ${url}`); + return { repo: m[1], path: m[2].replace(/\/$/, "") }; +} + +function deriveSlug(folderName) { + return folderName.replace(/^\d+-/, ""); +} + +function ghApi(endpoint) { + try { + return JSON.parse( + execSync(`gh api "${endpoint}"`, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }) + ); + } catch { + return null; + } +} + +function fetchYaml(repo, filePath) { + const data = ghApi(`repos/${repo}/contents/${filePath}`); + if (!data?.content) return null; + return parseYaml(Buffer.from(data.content, "base64").toString("utf8")); +} + +function listDir(repo, dirPath) { + const data = ghApi(`repos/${repo}/contents/${dirPath}`); + return Array.isArray(data) ? data.map((f) => f.name) : []; +} + +function deriveTopics(adventureTags) { + // Use all adventure tags as starting point; reviewer refines to level-specific subset. + return adventureTags; +} + +function buildLevel(raw, adventureTags) { + return { + ...raw, + topics: raw.topics || deriveTopics(adventureTags), + verification: raw.verification || VERIFICATION_STUB, + }; +} + +function mergeLevels(existing, incoming) { + const map = Object.fromEntries((existing || []).map((l) => [l.level, l])); + for (const l of incoming) map[l.level] = l; + return Object.values(map).sort( + (a, b) => (LEVEL_ORDER[a.level] ?? 99) - (LEVEL_ORDER[b.level] ?? 99) + ); +} + +function main() { + const url = process.env.ADVENTURE_URL; + if (!url) fail("ADVENTURE_URL environment variable is required"); + + const { repo, path: adventurePath } = parseAdventureUrl(url); + const folderName = adventurePath.split("/").pop(); + const slug = deriveSlug(folderName); + + console.log(`Syncing: ${repo}/${adventurePath} → ${slug}`); + + const indexData = fetchYaml(repo, `${adventurePath}/docs/index.yaml`); + if (!indexData) fail(`docs/index.yaml not found at ${adventurePath}/docs/`); + + const adventureTags = indexData.tags || []; + + const docsFiles = listDir(repo, `${adventurePath}/docs`); + const levelFileNames = docsFiles.filter((f) => f.endsWith(".yaml") && f !== "index.yaml").sort(); + if (levelFileNames.length === 0) fail("No level YAML files found in docs/"); + + const incomingLevels = []; + for (const fileName of levelFileNames) { + const raw = fetchYaml(repo, `${adventurePath}/docs/${fileName}`); + if (raw) { + incomingLevels.push(buildLevel(raw, adventureTags)); + console.log(` Fetched level: ${fileName}`); + } + } + + const adventureDir = resolve(ADVENTURES_DIR, slug); + const yamlPath = resolve(adventureDir, "adventure.yaml"); + const existing = existsSync(yamlPath) ? parseYaml(readFileSync(yamlPath, "utf8")) : null; + const mode = existing ? "update" : "create"; + console.log(`Mode: ${mode}`); + + // Build the combined adventure object using challenges repo field names. + // The generator accepts all aliases (name/title, emoji → icon, etc.). + const adventure = { + slug, + // Use whichever title field the challenges repo provides + ...(indexData.title ? { title: indexData.title } : { name: indexData.name }), + emoji: indexData.emoji, + // Preserve month if a previous PR already set it + month: existing?.month || currentMonth(), + tags: adventureTags, + ...(indexData.backstory?.length && { backstory: indexData.backstory }), + ...(indexData.overview?.length && { overview: indexData.overview }), + ...(indexData.rewards && { rewards: indexData.rewards }), + // Preserve contributor set by a reviewer; omit otherwise (PR checklist item) + ...(existing?.contributor && { contributor: existing.contributor }), + levels: mergeLevels(existing?.levels, incomingLevels), + }; + + mkdirSync(adventureDir, { recursive: true }); + writeFileSync(yamlPath, stringifyYaml(adventure, { lineWidth: 120, indent: 2 })); + console.log(`Written: src/data/adventures/${slug}/adventure.yaml`); + + // Create discussion JSON stubs for new levels only + for (const level of incomingLevels) { + const stubPath = resolve(adventureDir, `${level.level}-posts.json`); + if (!existsSync(stubPath)) { + writeFileSync( + stubPath, + JSON.stringify({ discussionUrl: "", discussionPosts: [], totalReplies: 0 }, null, 2) + "\n" + ); + console.log(` Created stub: ${level.level}-posts.json`); + } + } + + const adventureName = indexData.title || indexData.name || slug; + const levelIds = incomingLevels.map((l) => l.level).join(","); + + writeFileSync("/tmp/adventure-slug", slug); + writeFileSync("/tmp/adventure-name", adventureName); + writeFileSync("/tmp/adventure-levels", levelIds); + writeFileSync("/tmp/adventure-mode", mode); + + console.log(`\nDone: ${adventureName} (${levelIds})`); +} + +main(); diff --git a/src/data/adventures/blind-by-design/adventure.yaml b/src/data/adventures/blind-by-design/adventure.yaml index 2f2c185f..262dc6d1 100644 --- a/src/data/adventures/blind-by-design/adventure.yaml +++ b/src/data/adventures/blind-by-design/adventure.yaml @@ -1,5 +1,5 @@ slug: blind-by-design -name: "Blind by Design" +title: "Blind by Design" icon: FlaskConical month: "MAY 2026" story: >- @@ -7,7 +7,7 @@ story: >- Wire the SDK against a flagd sidecar (Beginner), layer evaluation context to target by cohort (Intermediate), then instrument flag evaluations with OpenTelemetry and roll back a misbehaving fractional rollout (Expert). All without redeploying. -technologies: +tags: - OpenFeature - flagd - Spring Boot @@ -65,7 +65,7 @@ rewards: description: "Credly badge to showcase the achievement" levels: - - id: beginner + - level: beginner name: "Stand up the Lab" difficulty: Beginner deadline: "2026-05-26T23:59:00+01:00" @@ -81,7 +81,7 @@ levels: - "What remote provider means in practice: the SDK calls a separate flag service (flagd) over gRPC, not parsing flags.json itself" - "What flags.json looks like for flagd (state, variants, defaultVariant)" - "Why hot-reload of the flag file matters operationally: configuration without redeploy" - devcontainer_path: ".devcontainer/04-blind-by-design_01-beginner/devcontainer.json" + devcontainer: 04-blind-by-design_01-beginner discussion_url: "/t/wire-openfeature-flagd-into-a-spring-boot-service-with-zero-setup-adventure-04-beginner/1419" intro: - >- @@ -164,7 +164,7 @@ levels: command: "./verify.sh" description: "Once you think you've solved the challenge, run the verification script. If it fails it will tell you which checks didn't pass. If it passes, it generates a Certificate of Completion you can paste into the discussion." - - id: intermediate + - level: intermediate name: "Outcome by Cohort" difficulty: Intermediate deadline: "2026-05-26T23:59:00+01:00" @@ -177,7 +177,7 @@ levels: - "How OpenFeature's transaction-context propagation works in a thread-per-request server, and why a ThreadLocalTransactionContextPropagator is the right primitive for Servlet-based apps" - "The difference between request-scoped context (the subject's species) and global evaluation context (the trial's country), and when each is the right tool" - "How hooks let you attach cross-cutting behaviour, audit logging today and OpenTelemetry tracing tomorrow, without modifying every flag evaluation call site" - devcontainer_path: ".devcontainer/04-blind-by-design_02-intermediate/devcontainer.json" + devcontainer: 04-blind-by-design_02-intermediate discussion_url: "/t/outcome-by-cohort-adventure-04-intermediate/1485" intro: - >- @@ -333,7 +333,7 @@ levels: command: "./verify.sh" description: "Once you think you've solved the challenge, run the verification script. If it fails it will tell you which checks didn't pass. If it passes, it generates a Certificate of Completion you can paste into the discussion." - - id: expert + - level: expert name: "Read the Chart" difficulty: Expert deadline: "2026-05-26T23:59:00+01:00" @@ -352,7 +352,7 @@ levels: - "How to author your own Hook: a tiny class that copies merged-eval-context attributes onto the active OTel span, closing the loop between why a flag resolved the way it did and what the operator sees in Tempo" - "How fractional rollout in flagd buckets users by targetingKey (same key, same bucket, every request) and how to read that bucketing off a dashboard" - "How a flag flip is a faster operational lever than a redeploy when a rollout is misbehaving: the difference between a one-line config change and a twenty-minute deployment" - devcontainer_path: ".devcontainer/04-blind-by-design_03-expert/devcontainer.json" + devcontainer: 04-blind-by-design_03-expert discussion_url: "/t/read-the-chart-adventure-04-expert/1530" intro: - >- diff --git a/src/data/adventures/building-cloudhaven/adventure.yaml b/src/data/adventures/building-cloudhaven/adventure.yaml index 2ea1df2c..c7a81ec0 100644 --- a/src/data/adventures/building-cloudhaven/adventure.yaml +++ b/src/data/adventures/building-cloudhaven/adventure.yaml @@ -1,11 +1,11 @@ slug: building-cloudhaven -name: Building CloudHaven +title: Building CloudHaven icon: Cloud month: JAN 2026 story: Join the Infrastructure Guild and modernize CloudHaven's infrastructure from manual provisioning to a self-service platform using Infrastructure as Code. A hands-on journey through infrastructure as code with OpenTofu and GitHub Actions. -technologies: +tags: - OpenTofu - Terraform - GitHub Actions @@ -30,7 +30,7 @@ backstory: - The Guild Master has assigned you to complete the modernization journey. - "Your mission: build the services and tools that will support CloudHaven's future growth." levels: - - id: beginner + - level: beginner name: The Foundation Stones difficulty: Beginner topics: @@ -40,7 +40,7 @@ levels: - Remote state management with GCS backend - Dynamic resource provisioning with for_each - Conditional resources with the enabled meta-argument, new in OpenTofu - devcontainer_path: .devcontainer/02-building-cloudhaven_01-beginner/devcontainer.json + devcontainer: 02-building-cloudhaven_01-beginner discussion_url: /t/practice-infrastructure-as-code-with-zero-setup-adventure-02-beginner/656 deadline: "2026-02-04T23:59:00+01:00" intro: @@ -109,7 +109,7 @@ levels: verification: command: "./smoke-test.sh" description: "Once you think you've solved the challenge, run the smoke test to verify your solution." - - id: intermediate + - level: intermediate name: The Modular Metropolis difficulty: Intermediate topics: @@ -120,7 +120,7 @@ levels: - Test-Driven Development (TDD) workflow - Input validation with custom rules - Refactoring infrastructure safely with moved blocks - devcontainer_path: .devcontainer/02-building-cloudhaven_02-intermediate/devcontainer.json + devcontainer: 02-building-cloudhaven_02-intermediate discussion_url: /t/adventure-02-building-cloudhaven-intermediate-the-modular-metropolis/723/10 deadline: "2026-02-04T23:59:00+01:00" intro: @@ -207,7 +207,7 @@ levels: verification: command: "./smoke-test.sh" description: "Once you think you've solved the challenge, run the smoke test to verify your solution." - - id: expert + - level: expert name: The Guardian Protocols difficulty: Expert topics: @@ -218,7 +218,7 @@ levels: - GitHub Actions for drift detection and plan/apply - Integration tests with service containers - Security scanning with Trivy - devcontainer_path: .devcontainer/02-building-cloudhaven_03-expert/devcontainer.json + devcontainer: 02-building-cloudhaven_03-expert discussion_url: /t/adventure-02-building-cloudhaven-expert-the-guardian-protocols/782/8 deadline: "2026-02-04T23:59:00+01:00" intro: diff --git a/src/data/adventures/echoes-lost-in-orbit/adventure.yaml b/src/data/adventures/echoes-lost-in-orbit/adventure.yaml index c1a0d3b7..28d315c2 100644 --- a/src/data/adventures/echoes-lost-in-orbit/adventure.yaml +++ b/src/data/adventures/echoes-lost-in-orbit/adventure.yaml @@ -1,10 +1,10 @@ slug: echoes-lost-in-orbit -name: Echoes Lost in Orbit +title: Echoes Lost in Orbit icon: Satellite month: DEC 2025 story: Restore interstellar communications by fixing broken GitOps setups, progressive delivery systems, and observability pipelines across three galactic missions. -technologies: +tags: - Argo CD - Argo Rollouts - OpenTelemetry @@ -27,7 +27,7 @@ backstory: dashboard shows no active deployments, and telemetry is suspiciously quiet. - You've been assigned to restore interstellar communication before the next critical mission. levels: - - id: beginner + - level: beginner name: Broken Echoes difficulty: Beginner topics: @@ -37,7 +37,7 @@ levels: - ApplicationSet templating & pitfalls - Environment isolation & namespaces - "Sync policies: automated, prune & self-heal" - devcontainer_path: .devcontainer/01-echoes-lost-in-orbit_beginner/devcontainer.json + devcontainer: 01-echoes-lost-in-orbit_beginner discussion_url: /t/adventure-01-echoes-lost-in-orbit-easy-broken-echoes/117/40 deadline: "2025-12-10T09:00:00+01:00" intro: @@ -106,7 +106,7 @@ levels: verification: command: "adventures/01-echoes-lost-in-orbit/beginner/smoke-test.sh" description: "Once you think you've solved the challenge, run the smoke test to verify your solution." - - id: intermediate + - level: intermediate name: The Silent Canary difficulty: Intermediate topics: @@ -117,7 +117,7 @@ levels: - Canary deployments & automated analysis - Write PromQL queries for health validation - Kube-state-metrics for deployment decisions - devcontainer_path: .devcontainer/01-echoes-lost-in-orbit_intermediate/devcontainer.json + devcontainer: 01-echoes-lost-in-orbit_intermediate discussion_url: /t/adventure-01-echoes-lost-in-orbit-intermediate-the-silent-canary/310/8 deadline: "2025-12-24T09:00:00+01:00" intro: @@ -243,7 +243,7 @@ levels: verification: command: "adventures/01-echoes-lost-in-orbit/intermediate/smoke-test.sh" description: "Once you think you've solved the challenge, run the smoke test to verify your solution." - - id: expert + - level: expert name: Hyperspace Operations & Transport difficulty: Expert topics: @@ -256,7 +256,7 @@ levels: - Spanmetrics connector (traces to metrics) - Detect idle canaries with traffic validation - Distributed tracing with Jaeger - devcontainer_path: .devcontainer/01-echoes-lost-in-orbit_expert/devcontainer.json + devcontainer: 01-echoes-lost-in-orbit_expert deadline: "2026-01-14T09:00:00+01:00" discussion_url: /t/adventure-01-echoes-lost-in-orbit-expert-hyperspace-operations-transport/351/4 intro: diff --git a/src/data/adventures/lex-imperfecta.generated.ts b/src/data/adventures/lex-imperfecta.generated.ts new file mode 100644 index 00000000..f2bdd758 --- /dev/null +++ b/src/data/adventures/lex-imperfecta.generated.ts @@ -0,0 +1,136 @@ +import { CODESPACES_BASE, COMMUNITY_URL } from "@/data/constants"; +import type { Adventure } from "./types"; + +export const LEX_IMPERFECTA: Adventure = { + id: "lex-imperfecta", + title: "Lex Imperfecta", + icon: "Scale", + month: "MAY 2026", + story: "The Roman Republic has built a sophisticated legal system to protect its citizens — but the laws were written in haste, and the exceptions were written too generously. Policies go unenforced, the wrong citizens are exempt, and something has slipped through the gates unnoticed. As a newly appointed Praetor, your mission is to restore order before chaos takes hold.", + tags: ["Kyverno", "Kubernetes"], + backstory: [ + "The Roman Republic has built a sophisticated legal system to protect its citizens — but the laws were written in haste, and the exceptions were written too generously. Policies go unenforced, the wrong citizens are exempt, and something has slipped through the gates unnoticed. As a newly appointed Praetor, your mission is to restore order before chaos takes hold.", + ], + overview: [ + "The Republic's legal system is in disarray — workloads run unchecked, required labels go missing, and privileged containers slip through the gates. As a newly appointed Praetor, your mission is to restore order by fixing broken Kyverno policies and enforcing proper admission control.", + ], + rewards: { + deadline: "TODO", + eligibility: "Complete all levels and post your solution in the community before the deadline to be eligible.", + tiers: [ + { label: "1st place", description: "50% voucher for a Linux Foundation certification" }, + { label: "Top 3", description: "Credly badge to showcase the achievement" }, + ], + rankingNote: "Ranking is determined by total points across all three levels. Points per level are awarded by submission order within the active week (100 for the first valid solution, 95 for the second, and so on; late submissions still earn 60).", + rankingRulesUrl: `${COMMUNITY_URL}/t/about-the-challenges-category/16`, + }, + levels: [ + { + id: "beginner", + name: "The Twelve Tables", + difficulty: "Beginner", + topics: ["Kyverno", "Kubernetes"], + audience: `Platform engineers, SREs, and developers curious about Kubernetes security — no prior Kyverno experience needed, but familiarity with basic \`kubectl\` and YAML will help.`, + learnings: [ + `How Kyverno [\`ValidatingPolicy\`](https://kyverno.io/docs/policy-types/validating-policy/) resources and [CEL validation expressions](https://kubernetes.io/docs/reference/using-api/cel/) work`, + `The difference between [\`Audit\`, \`Deny\`, and \`Warn\`](https://kyverno.io/docs/policy-types/validating-policy/) validation actions`, + "How to use [custom label keys](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) to enforce workload identity standards", + `How Kyverno [\`MutatingPolicy\`](https://kyverno.io/docs/policy-types/mutating-policy/) resources automatically patch incoming workloads at admission`, + ], + codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2Flex-imperfecta_beginner%2Fdevcontainer.json&quickstart=1`, + discussionUrl: "", + intro: ["Fix broken Kyverno policies to restore proper admission control."], + backstory: [ + "The Republic's legal scholars have been busy — perhaps too busy. In their haste to codify the Twelve Tables, the foundation of the Republic's legal system, they introduced errors that now threaten the city's order. Workloads that should be blocked are running freely, and workloads that should be allowed are being turned away at the gates.", + "Another scholar left a note: \"I tried to set up policies for privileged containers and required labels, but something's off — I can't figure out why the wrong things are getting through. There was also supposed to be a system for automatically issuing travel permits to foreign visitors, but that one is broken too.\"", + "Your mission: investigate the Kyverno policies and restore proper admission control before chaos reaches the city.", + ], + objective: [ + `All workloads **missing the \`republic.rome/gens\` label** blocked at admission with a clear policy violation message`, + "All workloads **running as privileged containers** blocked at admission with a clear policy violation message", + `All pods declaring **\`republic.rome/traveler: peregrinus\`** automatically receiving the **\`republic.rome/travel-permit: granted\`** label`, + "Confirmed that **all other workloads** deploy and run successfully in the cluster", + ], + architecture: [ + "The defining principle of the Twelve Tables was that Roman law was enforced **at the gates** — before a citizen could act, not after the damage was done. Kubernetes admission control works exactly the same way: Kyverno intercepts every request to create or update a workload and checks it against your policies *before* it reaches the cluster. A misconfigured policy doesn't just fail to enforce — it fails silently, letting non-compliant workloads slip through unnoticed while you assume everything is fine.", + `That's the situation you've inherited. Your Codespace comes with a Kubernetes cluster and Kyverno pre-installed. Three policies are already deployed — two \`ValidatingPolicy\` resources that validate workloads, and one \`MutatingPolicy\` that automatically stamps incoming pods with the right labels. All three are misconfigured. The policies live in \`manifests/policies/\`. You will edit them directly and re-apply with \`kubectl\`.`, + `The pods in \`manifests/pods/\` are there for reference only — **you don't need to edit them**.`, + "No GitOps, no dashboards — just you, the policies, and the cluster.", + ], + toolbox: [ + { name: "kubectl", description: "Apply and inspect cluster resources", url: "https://kubernetes.io/docs/reference/kubectl/" }, + { name: "kyverno CLI", description: "Test and lint policies locally before applying", url: "https://kyverno.io/docs/kyverno-cli/" }, + { name: "k9s", description: "Explore cluster resources in a terminal UI", url: "https://k9scli.io/" }, + ], + howToPlay: [ + { title: "Explore the Cluster", content: `When your Codespace is ready, four pods are already running — or trying to. Open a terminal and check what's going on: + +\`\`\`bash +kubectl get pods +\`\`\` + +Inspect why a pod was blocked or admitted: + +\`\`\`bash +kubectl describe pod <pod-name> +\`\`\` + +Check the policies that are in place: + +\`\`\`bash +kubectl get validatingpolicies +kubectl get validatingpolicy require-labels -o yaml +kubectl get validatingpolicy no-privileged-containers -o yaml + +kubectl get mutatingpolicies +kubectl get mutatingpolicy stamp-travel-permit -o yaml +\`\`\` + +You can also launch **k9s** for a terminal UI view of all cluster resources: + +\`\`\`bash +k9s +\`\`\` + +Navigate to \`ValidatingPolicy\` resources with \`:validatingpolicies\` and \`MutatingPolicy\` resources with \`:mutatingpolicies\` to inspect all three policies. +` }, + { title: "Fix the Policies", content: `Review the [Objective](#objective) and investigate what's wrong in \`manifests/policies/\`. + +All three broken policies are in \`manifests/policies/\`. Read them carefully — each has a different kind of misconfiguration. + +**Test Locally with the Kyverno CLI** + +Before applying to the cluster, you can use the \`kyverno\` CLI to test your policy changes locally against the workload manifests: + +\`\`\`bash +kyverno apply manifests/policies/require-labels.yaml --resource manifests/pods/missing-labels.yaml +kyverno apply manifests/policies/no-privileged-containers.yaml --resource manifests/pods/privileged.yaml +kyverno apply manifests/policies/stamp-travel-permit.yaml --resource manifests/pods/peregrinus.yaml +\`\`\` + +This gives you fast feedback without touching the cluster. + +**Apply to the Cluster** + +Once you're happy with your changes, re-apply everything: + +\`\`\`bash +make apply +\`\`\` + +This re-applies the policies and re-deploys all workloads so you immediately see the effect of your changes. +` }, + ], + helpfulLinks: [ + { title: "Kyverno ValidatingPolicy", url: "https://kyverno.io/docs/policy-types/validating-policy/", description: "Reference docs for ValidatingPolicy — the resource type you'll fix to block non-compliant workloads" }, + { title: "Kyverno MutatingPolicy", url: "https://kyverno.io/docs/policy-types/mutating-policy/", description: "Reference docs for MutatingPolicy — the resource type you'll fix to auto-stamp travel permits" }, + { title: "CEL Validation Expressions", url: "https://kubernetes.io/docs/reference/using-api/cel/", description: "How CEL expressions work in Kubernetes admission — what you'll write inside the policy rules" }, + { title: "Kyverno Playground", url: "https://playground.kyverno.io", description: "Test your CEL expressions interactively against sample resources before applying them to the cluster" }, + ], + verification: { + command: "./verify.sh", + description: "Once you think you've solved the challenge, run the verification script. If it fails it will tell you which checks didn't pass. If it passes, it generates a Certificate of Completion you can paste into the discussion.", + }, + }, + ], +}; diff --git a/src/data/adventures/the-ai-observatory/adventure.yaml b/src/data/adventures/the-ai-observatory/adventure.yaml index 47ebd372..cb2b3276 100644 --- a/src/data/adventures/the-ai-observatory/adventure.yaml +++ b/src/data/adventures/the-ai-observatory/adventure.yaml @@ -1,10 +1,10 @@ slug: the-ai-observatory -name: The AI Observatory +title: The AI Observatory icon: Telescope month: FEB 2026 story: Investigate a mysterious bandwidth anomaly at a remote research station by instrumenting its AI system with OpenTelemetry, OpenLLMetry, and Jaeger. -technologies: +tags: - OpenTelemetry - OpenLLMetry - Jaeger @@ -23,7 +23,7 @@ backstory: engineer, it's your job to instrument the AI, trace its activities, and uncover the root cause of the anomaly. - "Your mission: bring visibility to the station's AI and solve the mystery." levels: - - id: beginner + - level: beginner name: Calibrating the Lens difficulty: Beginner topics: @@ -33,7 +33,7 @@ levels: learnings: - Instrument Python AI apps with OpenLLMetry - Analyze traces in Jaeger - devcontainer_path: .devcontainer/03-the-ai-observatory_01-beginner/devcontainer.json + devcontainer: 03-the-ai-observatory_01-beginner discussion_url: /t/instrument-your-first-llm-adventure-03-beginner-is-live/865/8 deadline: "2026-03-08T23:59:00+01:00" intro: @@ -108,7 +108,7 @@ levels: command: ./verify.sh description: Once you think you've solved the challenge, run the verification script. If it fails it will tell you which checks didn't pass. If it passes, it generates a Certificate of Completion you can paste into the discussion. - - id: intermediate + - level: intermediate name: The Distracted Pilot difficulty: Intermediate topics: @@ -120,7 +120,7 @@ levels: - Instrument RAG pipelines with OpenLLMetry - Create custom OpenTelemetry metrics in Python - Write PromQL queries & recording rules in Prometheus - devcontainer_path: .devcontainer/03-the-ai-observatory_02-intermediate/devcontainer.json + devcontainer: 03-the-ai-observatory_02-intermediate discussion_url: /t/instrument-debug-a-rag-pipeline-adventure-03-intermediate-is-live/936/2 deadline: "2026-03-08T23:59:00+01:00" intro: @@ -218,7 +218,7 @@ levels: command: ./verify.sh description: Once you think you've solved the challenge, run the verification script. If it fails it will tell you which checks didn't pass. If it passes, it generates a Certificate of Completion you can paste into the discussion. - - id: expert + - level: expert name: The Noise Filter difficulty: Expert topics: @@ -228,7 +228,7 @@ levels: learnings: - OpenTelemetry GenAI semantic conventions - Tail sampling in the OTel Collector - devcontainer_path: .devcontainer/03-the-ai-observatory_03-expert/devcontainer.json + devcontainer: 03-the-ai-observatory_03-expert discussion_url: /t/reduce-telemetry-noise-adventure-03-expert-is-live/999/1 deadline: "2026-03-08T23:59:00+01:00" intro: diff --git a/src/pages/AdventureDetail.tsx b/src/pages/AdventureDetail.tsx index 618ca8d5..cd873ab1 100644 --- a/src/pages/AdventureDetail.tsx +++ b/src/pages/AdventureDetail.tsx @@ -1,13 +1,14 @@ import { type JSX } from "react"; import { useParams, Link, useLoaderData } from "react-router"; import type { MetaFunction, LoaderFunctionArgs } from "react-router"; -import { ArrowRight, FlaskConical, Satellite, Cloud, Telescope, type LucideIcon } from "lucide-react"; +import { ArrowRight, FlaskConical, Satellite, Cloud, Telescope, Scale, type LucideIcon } from "lucide-react"; const ADVENTURE_ICONS: Record<string, LucideIcon> = { FlaskConical, Satellite, Cloud, Telescope, + Scale, }; import { ADVENTURES, type AdventureLevel } from "@/data/adventures"; import { NotFoundPage } from "@/components/NotFoundPage"; diff --git a/src/pages/ChallengeDetail.tsx b/src/pages/ChallengeDetail.tsx index fcbebb2d..ce65cde2 100644 --- a/src/pages/ChallengeDetail.tsx +++ b/src/pages/ChallengeDetail.tsx @@ -2,6 +2,7 @@ import { type JSX } from "react"; import { useParams, Link, useLoaderData } from "react-router"; import type { MetaFunction, LoaderFunctionArgs } from "react-router"; import { ArrowLeft, Check, ExternalLink } from "lucide-react"; +import { MarkdownContent } from "@/components/MarkdownContent"; import { ADVENTURES } from "@/data/adventures"; import { TagChips } from "@/components/TagChips"; import { CodespacesButton } from "@/components/CodespacesButton"; @@ -111,7 +112,7 @@ const StructuredLayout = ({ adventure, level, rewardsBelowFold }: StructuredLayo {objective.map((item) => ( <li key={item} className="flex items-start gap-2.5 text-sm text-[hsl(var(--text-secondary))] leading-relaxed"> <Check size={14} className="mt-0.5 shrink-0 text-primary" aria-hidden="true" /> - <span>{item}</span> + <div className="min-w-0 [&>p]:m-0"><MarkdownContent source={item} /></div> </li> ))} </ul> @@ -126,7 +127,7 @@ const StructuredLayout = ({ adventure, level, rewardsBelowFold }: StructuredLayo {level.learnings.map((learning) => ( <li key={learning} className="flex items-start gap-2.5 text-sm text-[hsl(var(--text-secondary))] leading-relaxed"> <span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" aria-hidden="true" /> - <span>{learning}</span> + <div className="min-w-0 [&>p]:m-0"><MarkdownContent source={learning} /></div> </li> ))} </ul>