From bd34dbcfcca100d3acd42d31bb4fb746124075c9 Mon Sep 17 00:00:00 2001 From: Sinduri Guntupalli Date: Fri, 29 May 2026 17:05:09 +0200 Subject: [PATCH 1/3] feat: add sync-adventure workflow and script - Add scripts/sync-adventure.mjs to fetch and convert adventure YAMLs from the challenges repo into website-compatible format - Add .github/workflows/sync-adventure.yml to trigger the sync via workflow_dispatch - Update schemas/adventure.schema.json to support emoji/title/tags fields and relaxed level schema (emoji-to-icon mapping, title as alias for name) - Update scripts/generate-adventures.mjs to handle the updated schema - Update all existing adventure YAMLs to align with the revised schema - Add lex-imperfecta.generated.ts for the new adventure - Add Scale icon to AdventureDetail icon registry - Render objectives and learnings through MarkdownContent in ChallengeDetail Signed-off-by: Sinduri Guntupalli --- .github/workflows/sync-adventure.yml | 126 ++++++++++++ schemas/adventure.schema.json | 63 ++++-- scripts/generate-adventures.mjs | 126 ++++++++---- scripts/sync-adventure.mjs | 179 ++++++++++++++++++ .../adventures/blind-by-design/adventure.yaml | 16 +- .../building-cloudhaven/adventure.yaml | 16 +- .../echoes-lost-in-orbit/adventure.yaml | 16 +- .../adventures/lex-imperfecta.generated.ts | 136 +++++++++++++ .../the-ai-observatory/adventure.yaml | 16 +- src/pages/AdventureDetail.tsx | 3 +- src/pages/ChallengeDetail.tsx | 5 +- 11 files changed, 615 insertions(+), 87 deletions(-) create mode 100644 .github/workflows/sync-adventure.yml create mode 100644 scripts/sync-adventure.mjs create mode 100644 src/data/adventures/lex-imperfecta.generated.ts 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> From 50ac5bcac6bc1361f8d39f95a0f826865fff3cb8 Mon Sep 17 00:00:00 2001 From: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com> Date: Sun, 31 May 2026 14:54:56 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20sync-adventure=20workflow=20?= =?UTF-8?q?=E2=80=94=20levels=20param,=20robustness=20fixes,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `levels` workflow input to control which levels go live now vs appear as "coming soon" placeholders (upcomingLevels) in the More Levels card - Remove new-adventure and new-level workflows/scripts — fully superseded by sync-adventure for all content originating in the challenges repo - Fix workflow git add scope to include .generated.ts, index.ts, summaries.ts - Fix empty discussionUrl fallback: use ?? (nullish) not || (falsy) so an explicit discussion_url: "" is not silently overridden by community_url - Fix deadline: "TODO" emitting as a literal string that renders on screen; generator now emits "" so RewardsCard skips the deadline display - Fix duplicate "story" key in adventure.schema.json - Fix ChallengeDetail discussion link falling back to COMMUNITY_URL when discussionUrl is empty (consistent with DiscussionSection behaviour) - Add emoji warning in generator when an adventure emoji is not in EMOJI_ICON_MAP, pointing to both files that need updating - Extract LEVEL_DIFFICULTY_BY_ID, LEVEL_DIFFICULTY_BY_EMOJI, LEVEL_ORDER into scripts/lib/level-constants.mjs shared by both scripts - Add normalizeAdventureFields/normalizeLevelFields in generate-adventures.mjs to eliminate alias resolution duplicated across three generator functions - Convert sync-adventure.mjs API calls to async/parallel: index.yaml and docs listing fetched together, all level YAMLs fetched in one Promise.all - Collapse redundant validate-only + full generator passes in workflow into one step (full pass already validates before writing) - Update CLAUDE.md and README.md to reflect sync-adventure as the sole adventure-authoring workflow Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com> --- .github/workflows/new-adventure.yml | 129 -------- .github/workflows/new-level.yml | 120 ------- .github/workflows/sync-adventure.yml | 20 +- CLAUDE.md | 34 +- README.md | 40 +-- package.json | 4 +- schemas/adventure.schema.json | 4 - scripts/generate-adventures.mjs | 59 ++-- scripts/lib/level-constants.mjs | 20 ++ scripts/new-adventure.mjs | 313 ------------------ scripts/new-level.mjs | 240 -------------- scripts/sync-adventure.mjs | 107 ++++-- .../adventures/lex-imperfecta.generated.ts | 136 -------- src/pages/ChallengeDetail.tsx | 4 +- 14 files changed, 184 insertions(+), 1046 deletions(-) delete mode 100644 .github/workflows/new-adventure.yml delete mode 100644 .github/workflows/new-level.yml create mode 100644 scripts/lib/level-constants.mjs delete mode 100644 scripts/new-adventure.mjs delete mode 100644 scripts/new-level.mjs delete mode 100644 src/data/adventures/lex-imperfecta.generated.ts diff --git a/.github/workflows/new-adventure.yml b/.github/workflows/new-adventure.yml deleted file mode 100644 index f2307fc7..00000000 --- a/.github/workflows/new-adventure.yml +++ /dev/null @@ -1,129 +0,0 @@ -name: New Adventure - -on: - workflow_dispatch: - inputs: - id: - description: 'Adventure ID (kebab-case, e.g. "signal-in-the-storm")' - required: true - type: string - title: - description: 'Adventure title (e.g. "Signal in the Storm")' - required: true - type: string - month: - description: 'Release month (e.g. "JUN 2026")' - required: true - type: string - levels: - description: 'Comma-separated levels (e.g. "beginner" or "beginner,intermediate,expert")' - required: true - type: string - default: 'beginner' - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - scaffold: - 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: Validate inputs - env: - INPUT_ID: ${{ inputs.id }} - INPUT_LEVELS: ${{ inputs.levels }} - run: | - if ! echo "$INPUT_ID" | grep -qE '^[a-z0-9][a-z0-9-]*[a-z0-9]$'; then - echo "Error: Adventure ID must be kebab-case (lowercase letters, numbers, hyphens only)" - exit 1 - fi - if ! echo "$INPUT_LEVELS" | grep -qE '^(beginner|intermediate|expert)(,(beginner|intermediate|expert))*$'; then - echo "Error: Levels must be comma-separated list of beginner, intermediate, expert" - exit 1 - fi - - - name: Run scaffold script - env: - ADVENTURE_ID: ${{ inputs.id }} - ADVENTURE_TITLE: ${{ inputs.title }} - ADVENTURE_MONTH: ${{ inputs.month }} - ADVENTURE_LEVELS: ${{ inputs.levels }} - run: | - node scripts/new-adventure.mjs \ - --id "$ADVENTURE_ID" \ - --title "$ADVENTURE_TITLE" \ - --month "$ADVENTURE_MONTH" \ - --levels "$ADVENTURE_LEVELS" - - - name: Create Pull Request - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ADVENTURE_ID: ${{ inputs.id }} - ADVENTURE_TITLE: ${{ inputs.title }} - ADVENTURE_MONTH: ${{ inputs.month }} - ADVENTURE_LEVELS: ${{ inputs.levels }} - run: | - BRANCH="feat/adventure-${ADVENTURE_ID}" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git checkout -b "$BRANCH" - git add -A - git commit -m "feat: scaffold adventure ${ADVENTURE_ID}" - git push origin --delete "$BRANCH" 2>/dev/null || true - git push origin "$BRANCH" - { - printf '## New Adventure Scaffolded\n\n' - printf '| Field | Value |\n|---|---|\n' - printf '| ID | `%s` |\n' "$ADVENTURE_ID" - printf '| Title | %s |\n' "$ADVENTURE_TITLE" - printf '| Month | %s |\n' "$ADVENTURE_MONTH" - printf '| Levels | %s |\n\n' "$ADVENTURE_LEVELS" - printf '### Generated files\n\n' - printf -- '- `src/data/adventures/%s/adventure.yaml` — fill in the fields below\n' "$ADVENTURE_ID" - printf -- '- Discussion JSON stubs per level (update `discussionUrl` in each)\n' - printf -- '- Routes added to `react-router.config.ts`\n' - printf -- '- Sitemap entries added to `public/sitemap.xml`\n\n' - printf '### Required fields — fill in `adventure.yaml`\n\n' - printf -- '- [ ] `story` — 2-3 sentences: what tech is used and what each level does\n' - printf -- '- [ ] `tags` — technology names matching the topics across all levels\n\n' - printf 'For each level:\n\n' - printf -- '- [ ] `name` — display name (e.g. "Stand Up the Lab")\n' - printf -- '- [ ] `topics` — technology pill tags shown on the level card\n' - printf -- '- [ ] `learnings` — full sentences describing concrete skills gained\n' - printf -- '- [ ] `devcontainerPath` — path to `devcontainer.json` in the challenges repo\n' - printf -- '- [ ] `discussionUrl` — Discourse topic URL or relative path (e.g. `/t/slug/123`)\n' - printf -- '- [ ] `intro` — 1-2 sentences: what to wire up and what to prove works\n' - printf -- '- [ ] `objective` — verifiable outcomes a player can check with a command\n' - printf -- '- [ ] `toolbox` — CLI tools and services available in the Codespace\n' - printf -- '- [ ] `howToPlay` — step-by-step walkthrough; first step confirms broken state\n' - printf -- '- [ ] `verification` — keep defaults unless the script name or message differs\n\n' - printf '### Optional fields (uncomment in `adventure.yaml`)\n\n' - printf -- '- `contributor` — external contributor name, URL, and bio\n' - printf -- '- `backstory` (adventure) — narrative context for the whole adventure (1-3 paragraphs)\n' - printf -- '- `context` — "What you'\''ll be using" explainer section\n' - printf -- '- `rewards` — prize tiers, deadline, and ranking rules\n' - printf -- '- Per level: `audience`, `backstory`, `architecture` + `architectureDiagram` + `diagramAlt`, `helpfulLinks`, `deadline`\n\n' - printf '### Next steps\n\n' - printf '1. Fill in `src/data/adventures/%s/adventure.yaml` (required fields above)\n' "$ADVENTURE_ID" - printf '2. Add the adventure and each level route to `src/routes.ts`\n' - printf '3. Update `discussionUrl` in the YAML and each level `*-posts.json` file\n' - printf '4. Run `npm run generate`\n' - printf '5. Run `node scripts/refresh-discussions.mjs`\n' - printf '6. Add the adventure to `ADVENTURE_CATEGORIES` in `scripts/refresh-leaderboard.mjs` (set `categoryId` and per-level booleans), then run `node scripts/refresh-leaderboard.mjs`\n' - printf '7. Add each level URL to the `ROUTES` array in `e2e/smoke.spec.ts` and `src/test/seo.test.ts`, and to the `pages` array in `src/test/prerender.test.ts` with the expected `<title>` value\n' - printf '8. Update the routes table in `README.md`\n' - printf '9. Run all checks: `npm run lint && npm test && npm run build && npm run test:e2e`\n' - } > /tmp/pr-body.md - gh pr create \ - --title "feat: scaffold adventure — ${ADVENTURE_TITLE}" \ - --body-file /tmp/pr-body.md \ - --head "$BRANCH" diff --git a/.github/workflows/new-level.yml b/.github/workflows/new-level.yml deleted file mode 100644 index a6772e7d..00000000 --- a/.github/workflows/new-level.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: New Level - -on: - workflow_dispatch: - inputs: - adventure: - description: 'Adventure ID (e.g. "blind-by-design")' - required: true - type: string - level: - description: 'Level ID (beginner, intermediate, or expert)' - required: true - type: choice - options: - - beginner - - intermediate - - expert - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - scaffold: - 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: Validate inputs - env: - INPUT_ADVENTURE: ${{ inputs.adventure }} - run: | - if ! echo "$INPUT_ADVENTURE" | grep -qE '^[a-z0-9][a-z0-9-]*[a-z0-9]$'; then - echo "Error: Adventure ID must be kebab-case (lowercase letters, numbers, hyphens only)" - exit 1 - fi - - - name: Run new-level script - id: scaffold - env: - ADVENTURE_ID: ${{ inputs.adventure }} - LEVEL_ID: ${{ inputs.level }} - run: | - OUTPUT=$(node scripts/new-level.mjs \ - --adventure "$ADVENTURE_ID" \ - --level "$LEVEL_ID" 2>&1) - echo "$OUTPUT" - - # Extract the YAML snippet (from " - id:" through to just before "Next steps:") - SNIPPET=$(echo "$OUTPUT" | awk '/^ - id:/{found=1} /^Next steps:/{exit} found') - echo "snippet<<EOF" >> "$GITHUB_OUTPUT" - echo "$SNIPPET" >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - - - name: Create Pull Request - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ADVENTURE: ${{ inputs.adventure }} - LEVEL: ${{ inputs.level }} - SNIPPET: ${{ steps.scaffold.outputs.snippet }} - run: | - BRANCH="feat/${ADVENTURE}-${LEVEL}" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git checkout -b "$BRANCH" - git add -A - git commit -m "feat: add ${LEVEL} level to ${ADVENTURE}" - git push origin --delete "$BRANCH" 2>/dev/null || true - git push origin "$BRANCH" - { - printf '## New Level Added\n\n' - printf '| Field | Value |\n|---|---|\n' - printf '| Adventure | `%s` |\n' "$ADVENTURE" - printf '| Level | `%s` |\n\n' "$LEVEL" - printf '### Generated files\n\n' - printf -- '- `src/data/adventures/%s/%s-posts.json` — update `discussionUrl`\n' "$ADVENTURE" "$LEVEL" - printf -- '- Prerender entry added to `react-router.config.ts`\n' - printf -- '- Sitemap entry added to `public/sitemap.xml`\n' - printf -- '- `has_%s` set to `true` in `ADVENTURE_CATEGORIES` in `scripts/refresh-leaderboard.mjs`\n\n' "$LEVEL" - printf '### Paste this into the `levels` array in `adventure.yaml`\n\n' - printf '```yaml\n%s\n```\n\n' "$SNIPPET" - printf '### Required fields — fill in after pasting\n\n' - printf -- '- [ ] `name` — display name (e.g. "Stand Up the Lab")\n' - printf -- '- [ ] `topics` — technology pill tags shown on the level card\n' - printf -- '- [ ] `learnings` — full sentences describing concrete skills gained\n' - printf -- '- [ ] `devcontainerPath` — path to `devcontainer.json` in the challenges repo\n' - printf -- '- [ ] `discussionUrl` — Discourse topic URL or relative path (e.g. `/t/slug/123`)\n' - printf -- '- [ ] `intro` — 1-2 sentences: what to wire up and what to prove works\n' - printf -- '- [ ] `objective` — verifiable outcomes a player can check with a command\n' - printf -- '- [ ] `toolbox` — CLI tools and services available in the Codespace\n' - printf -- '- [ ] `howToPlay` — step-by-step walkthrough; first step confirms broken state\n' - printf -- '- [ ] `verification` — keep defaults unless the script name or message differs\n\n' - printf '### Optional fields (uncomment after pasting)\n\n' - printf -- '- `audience` — who this level is aimed at and what prior knowledge helps\n' - printf -- '- `backstory` — narrative context that sets the scene for this specific level\n' - printf -- '- `architecture` — technical setup (services, ports, how they connect)\n' - printf -- '- `architectureDiagram` + `diagramAlt` — SVG diagram filename and alt text\n' - printf -- '- `helpfulLinks` — reference docs the player will need\n' - printf -- '- `deadline` — submission deadline if rewards are active\n\n' - printf '### Next steps\n\n' - printf '1. Paste the snippet above into `src/data/adventures/%s/adventure.yaml` and fill in required fields\n' "$ADVENTURE" - printf '2. Add the level route to `src/routes.ts`\n' - printf '3. Update `discussionUrl` in the YAML and `src/data/adventures/%s/%s-posts.json`\n' "$ADVENTURE" "$LEVEL" - printf '4. Run `npm run generate`\n' - printf '5. Run `node scripts/refresh-discussions.mjs`\n' - printf '6. Run `node scripts/refresh-leaderboard.mjs` (requires `DISCOURSE_API_KEY`)\n' - printf '7. Add the level URL to the `ROUTES` array in `e2e/smoke.spec.ts` and `src/test/seo.test.ts`, and to the `pages` array in `src/test/prerender.test.ts` with the expected `<title>` value\n' - printf '8. Update the routes table in `README.md`\n' - printf '9. Run all checks: `npm run lint && npm test && npm run build && npm run test:e2e`\n' - } > /tmp/pr-body.md - gh pr create \ - --title "feat: add ${LEVEL} level — ${ADVENTURE}" \ - --body-file /tmp/pr-body.md \ - --head "$BRANCH" diff --git a/.github/workflows/sync-adventure.yml b/.github/workflows/sync-adventure.yml index 4429d203..76cac875 100644 --- a/.github/workflows/sync-adventure.yml +++ b/.github/workflows/sync-adventure.yml @@ -9,6 +9,14 @@ on: (e.g. https://github.com/off-on-dev/open-source-challenges/tree/main/adventures/05-lex-imperfecta) required: true type: string + levels: + description: > + Comma-separated level IDs to make live now (e.g. beginner or beginner,intermediate). + Levels found in the challenges repo but not listed here will be added as "coming soon" + placeholders in the More Levels sidebar card. + Leave blank to sync all levels as live. + required: false + type: string env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true @@ -44,12 +52,10 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ADVENTURE_URL: ${{ inputs.adventure_url }} + LEVELS_TO_SYNC: ${{ inputs.levels }} run: node scripts/sync-adventure.mjs - - name: Validate generated YAML - run: node scripts/generate-adventures.mjs --validate-only - - - name: Regenerate TypeScript + - name: Validate and regenerate TypeScript run: node scripts/generate-adventures.mjs - name: Create pull request @@ -67,7 +73,11 @@ jobs: 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 add \ + src/data/adventures/${SLUG}/ \ + src/data/adventures/${SLUG}.generated.ts \ + src/data/adventures/index.ts \ + src/data/adventures/summaries.ts git commit -m "feat: sync adventure ${SLUG} from challenges repo (${MODE})" git push origin --delete "$BRANCH" 2>/dev/null || true diff --git a/CLAUDE.md b/CLAUDE.md index 94d633f1..2dc6578f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,8 +88,7 @@ public/ deploy.yml # Production deploy to GitHub Pages (push to main) preview.yml # PR preview deploy (runs smoke tests before deploying) refresh-community-data.yml # Hourly discussion and leaderboard data refresh - new-adventure.yml # workflow_dispatch: scaffold a new adventure - new-level.yml # workflow_dispatch: add a level to an existing adventure + sync-adventure.yml # workflow_dispatch: sync an adventure from the challenges repo validate-adventures.yml # PR check: validates adventure YAML, routes, and sitemap consistency ``` @@ -110,8 +109,6 @@ npm run test:e2e # Playwright smoke tests (requires npm run build first) npm run preview # Copy 404 fallback and serve the production build locally npm run generate # Regenerate TypeScript from adventure YAML files npm run generate:validate # Validate YAML against schema without writing files -npm run new-adventure # Scaffold a new adventure YAML and stub files -npm run new-level # Add a new level to an existing adventure npx shadcn@latest add <component> # Add a shadcn/ui component ``` @@ -503,28 +500,21 @@ When adding a new route to `src/routes.ts`, follow these rules by route type: - Redirect routes: do not add to `sitemap.xml` or `README.md`. - Catch-all routes: do not add anywhere. -### When adding a new adventure +### When adding a new adventure or a new level to an existing adventure -Complete checklist for every new adventure: +Adventures and levels are synced from the challenges repo via the `sync-adventure` GitHub Actions workflow (Actions tab → Sync Adventure from Challenges Repo → Run workflow). -1. Run `npm run new-adventure` to scaffold a new `src/data/adventures/<id>/adventure.yaml`. -2. Run `npm run generate` to produce `<id>.generated.ts` and update `index.ts`. -3. Add the adventure detail route and all level routes to `src/routes.ts`. -4. Add all URLs to `public/sitemap.xml`. -5. Add all URLs to the `prerender` array in `react-router.config.ts`. -6. Create a per-level discussion JSON file at `src/data/adventures/<adventure-id>/<level-id>-posts.json` with `{ "discussionUrl": "<full-topic-url>" }`. -7. Run `node scripts/refresh-discussions.mjs` to fetch discussion posts. -8. Add the adventure to `ADVENTURE_CATEGORIES` in `scripts/refresh-leaderboard.mjs` with the correct `categoryId` and level booleans. -9. Run `node scripts/refresh-leaderboard.mjs` to create `leaderboard.json`. -10. Update the routes table in `README.md`. +Inputs: +- `adventure_url` — 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`) +- `levels` — comma-separated level IDs to make live now (e.g. `beginner` or `beginner,intermediate`). Levels present in the challenges repo but not listed are added as "coming soon" placeholders. Leave blank to sync all levels. -### When adding a new level to an existing adventure +The workflow opens a PR with a checklist. Before merging, complete all items in that checklist, including: -1. Run `npm run new-level -- --adventure <id> --level <id>`. This scaffolds `<level-id>-posts.json`, adds the prerender entry to `react-router.config.ts`, and adds the URL to `public/sitemap.xml`. -2. Add the level fields to `src/data/adventures/<id>/adventure.yaml` and run `npm run generate`. -3. Add the level route to `src/routes.ts`. -4. Add the level to `ADVENTURE_CATEGORIES` in `scripts/refresh-leaderboard.mjs` and run `node scripts/refresh-leaderboard.mjs`. -5. Add the level URL to the `ROUTES` array in `e2e/smoke.spec.ts` and `src/test/seo.test.ts`, and to the `pages` array in `src/test/prerender.test.ts` with the expected `<title>` value. +1. Add the adventure detail route and all level routes to `src/routes.ts`. +2. Add all URLs to `public/sitemap.xml` and the `prerender` array in `react-router.config.ts`. +3. Add the adventure to `ADVENTURE_CATEGORIES` in `scripts/refresh-leaderboard.mjs` and run `node scripts/refresh-leaderboard.mjs`. +4. Run `node scripts/refresh-discussions.mjs` after setting `discussionUrl` in each `*-posts.json`. +5. Add each level URL to the `ROUTES` array in `e2e/smoke.spec.ts` and `src/test/seo.test.ts`, and to the `pages` array in `src/test/prerender.test.ts` with the expected `<title>` value. 6. Update the routes table in `README.md`. --- diff --git a/README.md b/README.md index 4e18e9a0..ff60c4fc 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ schemas/ adventure.schema.json # JSON Schema for adventure YAML validation scripts/ generate-adventures.mjs # YAML -> TypeScript codegen (runs as prebuild hook) - new-adventure.mjs # Scaffold a new adventure YAML template + sync-adventure.mjs # Fetch and transform adventure YAML from the challenges repo public/ fonts/ # Self-hosted Inter, Syne, and JetBrains Mono font files sitemap.xml @@ -86,7 +86,7 @@ Adventures are authored as YAML at `src/data/adventures/<id>/adventure.yaml` and - **Source of truth:** the YAML files. Never edit `*.generated.ts` or `index.ts` by hand. - **Schema:** `schemas/adventure.schema.json` (JSON Schema Draft 2020-12). Run `npm run generate:validate` to check. -- **Scaffold a new adventure:** `node scripts/new-adventure.mjs` +- **Sync from challenges repo:** use the `sync-adventure` GitHub Actions workflow (see Adding Adventures below). - **Generated outputs:** `<id>.generated.ts` (one per adventure) + `index.ts` (barrel with `ADVENTURES`, `ALL_TAGS`, `ADVENTURE_CONTRIBUTORS`, `getLevelsByTag`, `tagToSlug`, `slugToTag`). - **Generated files are committed** so the dev server works without an extra step. @@ -186,36 +186,30 @@ PR preview builds set the `VITE_BASE_PATH` environment variable to `/pr-preview/ ## Adding Adventures and Levels -New adventures and levels can be scaffolded via GitHub Actions (recommended) or locally via scripts. +Adventures and levels are synced from the challenges repo via the **Sync Adventure from Challenges Repo** GitHub Actions workflow. ### Via GitHub Actions -Go to the repository's **Actions** tab, select the workflow, click **Run workflow**, and fill in the form inputs. +Go to the **Actions** tab, select **Sync Adventure from Challenges Repo**, and click **Run workflow**. -| Workflow | Inputs | What it does | +| Input | Required | Description | |---|---|---| -| **New Adventure** | `id`, `title`, `month`, `levels` (comma-separated) | Scaffolds a full adventure: TS file with TODOs, discussion JSON stubs, patches `react-router.config.ts` and `sitemap.xml`, opens a PR | -| **New Level** | `adventure` (existing ID), `level` (beginner/intermediate/expert) | Adds a level to an existing adventure: discussion JSON stub, patches config and sitemap, opens a PR with a TS snippet to paste | +| `adventure_url` | Yes | 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`) | +| `levels` | No | Comma-separated level IDs to make live now (e.g. `beginner` or `beginner,intermediate`). Levels in the challenges repo not listed here appear as "coming soon" placeholders. Leave blank to sync all. | -Both workflows commit the scaffolded files to a new branch and open a PR automatically using the GitHub CLI (`gh pr create`). The PR description includes next steps (fill in content TODOs, run verification, etc.). +The workflow fetches content from the challenges repo, generates the TypeScript data files, and opens a PR on branch `feat/adventure-<slug>`. The PR description includes a checklist of steps to complete before merging. -### Via local scripts +### After the PR is opened -```sh -# Scaffold a new adventure with all required files -node scripts/new-adventure.mjs --id "signal-in-the-storm" --title "Signal in the Storm" --month "JUL 2026" --levels beginner,intermediate,expert - -# Add a level to an existing adventure -node scripts/new-level.mjs --adventure "blind-by-design" --level expert -``` - -After running either script, fill in the TODO placeholders in the generated TS file, then: +Complete all items in the PR checklist, including: -1. Add the `discussionUrl` in `src/data/adventures/<id>/<level>.json` -2. Run `node scripts/refresh-discussions.mjs` to fetch posts -3. Add the adventure's `categoryId` and `levelCount` to `ADVENTURE_CATEGORIES` in `scripts/refresh-leaderboard.mjs` -4. Run `node scripts/refresh-leaderboard.mjs` to fetch leaderboard data -5. Run `npm run lint && npm test && npm run build && npm run test:e2e` +1. Add the adventure detail route and all level routes to `src/routes.ts` +2. Add all URLs to `public/sitemap.xml` and the `prerender` array in `react-router.config.ts` +3. Set `discussionUrl` in each `*-posts.json`, then run `node scripts/refresh-discussions.mjs` +4. Add the adventure to `ADVENTURE_CATEGORIES` in `scripts/refresh-leaderboard.mjs` and run `node scripts/refresh-leaderboard.mjs` +5. Add each level URL to `ROUTES` in `e2e/smoke.spec.ts` and `src/test/seo.test.ts`, and to `pages` in `src/test/prerender.test.ts` with the expected `<title>` +6. Update the routes table in `README.md` +7. Run `npm run lint && npm test && npm run build && npm run test:e2e` ### Leaderboard data diff --git a/package.json b/package.json index 15ae9532..b935eb57 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,7 @@ "test:e2e": "playwright test", "generate": "node scripts/generate-adventures.mjs", "generate:validate": "node scripts/generate-adventures.mjs --validate-only", - "prebuild": "node scripts/generate-adventures.mjs", - "new-adventure": "node scripts/new-adventure.mjs", - "new-level": "node scripts/new-level.mjs" + "prebuild": "node scripts/generate-adventures.mjs" }, "dependencies": { "class-variance-authority": "^0.7.1", diff --git a/schemas/adventure.schema.json b/schemas/adventure.schema.json index ca6f7824..f92bd7a8 100644 --- a/schemas/adventure.schema.json +++ b/schemas/adventure.schema.json @@ -33,10 +33,6 @@ "pattern": "^[A-Z]{3} \\d{4}$", "description": "Release month in format 'MMM YYYY' (e.g. 'MAY 2026')." }, - "story": { - "type": "string", - "description": "One-paragraph summary of the adventure." - }, "story": { "type": "string", "description": "One-paragraph card summary. Omit to derive from backstory[0]." diff --git a/scripts/generate-adventures.mjs b/scripts/generate-adventures.mjs index a7ae100f..7626e10f 100644 --- a/scripts/generate-adventures.mjs +++ b/scripts/generate-adventures.mjs @@ -28,6 +28,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { parse as parseYaml } from "yaml"; +import { LEVEL_DIFFICULTY_BY_EMOJI } from "./lib/level-constants.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, ".."); @@ -45,12 +46,6 @@ const EMOJI_ICON_MAP = { "⚖️": "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 = @@ -126,6 +121,27 @@ function isValidISODeadline(str) { return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/.test(str); } +/** Resolve alias fields on an adventure YAML object to canonical field values. */ +function normalizeAdventureFields(data) { + return { + title: data.title || data.name, + story: data.story || (data.backstory?.length > 0 ? data.backstory[0] : ""), + icon: data.icon || (data.emoji ? EMOJI_ICON_MAP[data.emoji] : undefined), + }; +} + +/** Resolve alias fields on a level YAML object to canonical field values. */ +function normalizeLevelFields(level) { + return { + id: level.level, + name: level.name || level.title, + difficulty: level.difficulty || LEVEL_DIFFICULTY_BY_EMOJI[level.emoji], + learnings: level.learnings || level.what_you_learn, + intro: level.intro || (level.summary ? [level.summary] : undefined), + discussionUrl: (level.discussion_url ?? level.community_url) ?? "", + }; +} + // --- YAML Discovery --- function findAdventureYamls() { @@ -170,7 +186,7 @@ function validateAdventure(data, id) { 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]; + const difficulty = level.difficulty || LEVEL_DIFFICULTY_BY_EMOJI[level.emoji]; if (!difficulty) errors.push(`${prefix}: Missing difficulty (or emoji 🟢/🟡/🔴)`); else if (!["Beginner", "Intermediate", "Expert"].includes(difficulty)) { errors.push(`${prefix}: Invalid difficulty "${difficulty}"`); @@ -203,14 +219,11 @@ 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 || ""; + const { id: levelId, name: levelName, difficulty: levelDifficulty, learnings: levelLearnings, intro: levelIntro, discussionUrl: levelDiscussionUrl } = normalizeLevelFields(level); lines.push(`${i}{`); - lines.push(`${i2}id: "${escapeDoubleQuoted(level.level)}",`); - lines.push(`${i2}name: "${escapeDoubleQuoted(level.name || level.title)}",`); + lines.push(`${i2}id: "${escapeDoubleQuoted(levelId)}",`); + lines.push(`${i2}name: "${escapeDoubleQuoted(levelName)}",`); lines.push(`${i2}difficulty: "${levelDifficulty}",`); if (level.topics) { @@ -346,9 +359,10 @@ 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); + const { title: adventureTitle, story: adventureStory, icon: adventureIcon } = normalizeAdventureFields(data); + if (data.emoji && !adventureIcon) { + warn(`${data.slug}: emoji "${data.emoji}" is not in EMOJI_ICON_MAP — add it to scripts/generate-adventures.mjs and src/pages/AdventureDetail.tsx`); + } lines.push(`export const ${constName}: Adventure = {`); lines.push(` id: "${data.slug}",`); @@ -379,7 +393,8 @@ function generateAdventureTs(data) { const rankingNote = data.rewards.ranking_note ?? DEFAULT_REWARDS_RANKING_NOTE; const rankingRulesUrl = data.rewards.ranking_rules_url ?? DEFAULT_REWARDS_RANKING_RULES_PATH; lines.push(` rewards: {`); - lines.push(` deadline: "${escapeDoubleQuoted(data.rewards.deadline)}",`); + const rewardsDeadline = data.rewards.deadline === "TODO" ? "" : data.rewards.deadline; + lines.push(` deadline: "${escapeDoubleQuoted(rewardsDeadline)}",`); lines.push(` eligibility: "${escapeDoubleQuoted(eligibility)}",`); lines.push(` tiers: [`); for (const tier of data.rewards.tiers) { @@ -432,8 +447,7 @@ 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] : ""); + const { title: summaryTitle, story: summaryStory } = normalizeAdventureFields(data); lines.push(` id: "${data.slug}",`); lines.push(` title: "${escapeDoubleQuoted(summaryTitle)}",`); lines.push(` month: "${data.month}",`); @@ -445,10 +459,9 @@ function generateSummariesTs(adventures) { lines.push(` levels: [`); for (const level of data.levels) { lines.push(` {`); - 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)}",`); + const { id: summaryId, name: summaryName, difficulty: summaryDifficulty, learnings: summaryLearnings } = normalizeLevelFields(level); + lines.push(` id: "${escapeDoubleQuoted(summaryId)}",`); + lines.push(` name: "${escapeDoubleQuoted(summaryName)}",`); lines.push(` difficulty: "${summaryDifficulty}",`); if (level.topics && level.topics.length > 0) { lines.push(` topics: [${level.topics.map((t) => `"${escapeDoubleQuoted(t)}"`).join(", ")}],`); diff --git a/scripts/lib/level-constants.mjs b/scripts/lib/level-constants.mjs new file mode 100644 index 00000000..78a2a0f4 --- /dev/null +++ b/scripts/lib/level-constants.mjs @@ -0,0 +1,20 @@ +/** Maps the level ID field to the canonical difficulty string. */ +export const LEVEL_DIFFICULTY_BY_ID = { + beginner: "Beginner", + intermediate: "Intermediate", + expert: "Expert", +}; + +/** Maps difficulty-indicator emoji to the canonical difficulty string. */ +export const LEVEL_DIFFICULTY_BY_EMOJI = { + "🟢": "Beginner", + "🟡": "Intermediate", + "🔴": "Expert", +}; + +/** Canonical sort order for level IDs. Unknown IDs sort last. */ +export const LEVEL_ORDER = { + beginner: 0, + intermediate: 1, + expert: 2, +}; diff --git a/scripts/new-adventure.mjs b/scripts/new-adventure.mjs deleted file mode 100644 index 110433dc..00000000 --- a/scripts/new-adventure.mjs +++ /dev/null @@ -1,313 +0,0 @@ -#!/usr/bin/env node - -/** - * Scaffold a new adventure with all required files and config entries. - * - * Usage: - * node scripts/new-adventure.mjs --id "signal-in-the-storm" --title "Signal in the Storm" --month "JUN 2026" --levels beginner,intermediate,expert - * - * What it generates: - * - src/data/adventures/<id>/adventure.yaml (template with TODOs) - * - src/data/adventures/<id>/<level>-posts.json (discussion stubs) - * - Patches: react-router.config.ts, public/sitemap.xml - * - * After filling in the YAML, run: npm run generate - */ - -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, ".."); - -function parseArgs(argv) { - const args = {}; - for (let i = 2; i < argv.length; i++) { - if (argv[i].startsWith("--")) { - const key = argv[i].slice(2); - const value = argv[i + 1]; - if (!value || value.startsWith("--")) { - args[key] = true; - } else { - args[key] = value; - i++; - } - } - } - return args; -} - -function fail(msg) { - console.error(`\x1b[31mError:\x1b[0m ${msg}`); - process.exit(1); -} - -const args = parseArgs(process.argv); - -if (!args.id) fail("--id is required (e.g. --id \"signal-in-the-storm\")"); -if (!args.title) fail("--title is required (e.g. --title \"Signal in the Storm\")"); -if (!args.month) fail("--month is required (e.g. --month \"JUN 2026\")"); -if (!args.levels) fail("--levels is required (e.g. --levels beginner,intermediate,expert)"); - -const id = args.id; -const title = args.title; -const month = args.month; -const levels = args.levels.split(",").map((l) => l.trim()); - -// Validate inputs -if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) { - fail("--id must be kebab-case (lowercase letters, numbers, hyphens; cannot start/end with hyphen)"); -} -const VALID_LEVELS = ["beginner", "intermediate", "expert"]; -for (const level of levels) { - if (!VALID_LEVELS.includes(level)) { - fail(`Invalid level "${level}". Must be one of: ${VALID_LEVELS.join(", ")}`); - } -} - -const ADVENTURES_DIR = resolve(ROOT, "src/data/adventures"); -const adventureDataDir = resolve(ADVENTURES_DIR, id); -const yamlPath = resolve(adventureDataDir, "adventure.yaml"); - -if (existsSync(yamlPath)) { - fail(`Adventure YAML already exists: src/data/adventures/${id}/adventure.yaml`); -} - -// 1. Create adventure directory and per-level discussion JSON stubs -mkdirSync(adventureDataDir, { recursive: true }); - -for (const level of levels) { - const jsonPath = resolve(adventureDataDir, `${level}-posts.json`); - if (!existsSync(jsonPath)) { - writeFileSync( - jsonPath, - JSON.stringify({ discussionUrl: "TODO: Add Discourse topic URL" }, null, 2) + "\n" - ); - console.log(` Created: src/data/adventures/${id}/${level}-posts.json`); - } -} - -// 2. Generate the adventure YAML template -const difficultyMap = { - beginner: "Beginner", - intermediate: "Intermediate", - expert: "Expert", -}; - -const levelEntries = levels - .map((level) => { - const difficulty = difficultyMap[level]; - return ` - id: ${level} - # required | e.g. "Stand Up the Lab" / "Outcome by Cohort" / "Lights On" - name: "TODO: Replace with level display name" - difficulty: ${difficulty} - topics: - # required | technology and tool names shown as pill tags on the level card. - - "TODO: Replace with technology name" - - "TODO: Replace with another technology name" - learnings: - # required | full sentences describing concrete skills or insights gained. Use >- for prose with colons. - - >- - TODO: How the client and provider work together: the SDK is provider-agnostic and plugs in via dependency only - - >- - TODO: Why hot-reload matters operationally: editing the flag file flips behaviour on the next request with no redeploy - devcontainerPath: ".devcontainer/TODO/devcontainer.json" # required - discussionUrl: "/t/TODO" # required - intro: - # required | 1-2 sentences: what the player will wire up and what they will prove works. - - >- - TODO: Wire the SDK into the service so flag evaluations are resolved by a sidecar running alongside your Codespace. - Prove that editing the flag file flips the response on the next request without restarting anything. - objective: - # required | verifiable outcomes a player can check with a command. - - >- - TODO: curl http://localhost:8080/ returns a value resolved from the flag file, not the hard-coded fallback - - >- - TODO: Editing the flag file and re-running curl returns the new value without restarting the service - # optional | uncomment to describe who this level is aimed at and what prior knowledge helps. - # audience: >- - # Best suited for platform engineers and developers new to feature flagging. Familiarity with basic - # Java and Spring Boot helps but no prior SDK experience is needed. - # optional | narrative context that sets the scene for this specific level. - # backstory: - # - >- - # The service is returning a hard-coded response. The SDK was integrated last quarter but the provider - # was never registered, so every request falls back to the default. Complete the wiring so the service - # reads from the flag file instead. - # optional | describe the technical setup (services, ports, how they connect). - # architecture: - # - >- - # Two containers run side-by-side: the app on http://localhost:8080 and a sidecar on port 8013. - # - >- - # Edit the flag file through the IDE; the file watcher picks up changes within about a second. - toolbox: - # required | list the CLI tools and services available in the Codespace. Add a url field for external docs. - - name: "TODO: ./run.sh" - description: "TODO: Starts the service; also available via F5 in VS Code" - - name: "curl" - description: "TODO: sends requests to http://localhost:8080/ to confirm flags are being evaluated" - url: "https://curl.se/" - howToPlay: - # required | walk the player from the broken state to a working solution, one titled step at a time. - # Use fenced code blocks for commands. First step: confirm the broken state. Last step: run the verifier. - - title: "Confirm the Broken State" - body: | - TODO: Start the service and confirm the hard-coded fallback is returned: - - \`\`\`sh - ./run.sh - curl http://localhost:8080/ - # returns the fallback — this is the broken state - \`\`\` - - title: "TODO: Replace with main fix step title" - body: >- - TODO: Replace with instructions for the main fix. What file to open, what to add or change. - - title: "Verify the Fix" - body: | - TODO: Edit the flag file and re-run curl without restarting anything: - - \`\`\`sh - curl http://localhost:8080/ - # now returns the flag-resolved value - \`\`\` - helpfulLinks: - # optional | reference docs the player will need. label is the link text; url must be a full https:// URL. - - label: "TODO: SDK documentation" - url: https://example.com/docs - - label: "TODO: Flag definition reference" - url: https://example.com/flags - verification: - # required | keep the defaults unless the verification script name or message differs. - command: "./verify.sh" - description: "Once you think you've solved the challenge, run the verification script."`; - }) - .join("\n\n"); - -const yamlContent = `id: ${id} -title: "${title}" -month: "${month}" -# required | 2-3 sentences: what technology is used and what each level does. -story: >- - TODO: Replace with a 2-3 sentence summary. e.g. Three levels of OpenFeature with flagd as the provider, - in a Java + Spring Boot service. Wire the SDK (Beginner), add cohort targeting (Intermediate), - then instrument with OpenTelemetry (Expert). -tags: - # required | technology and tool names. Mirror the topics used across all levels. - - "TODO: Replace with technology name" - - "TODO: Replace with another technology name" - -# optional | uncomment and fill in if the adventure has an external contributor: -# contributor: -# name: "Contributor Name" -# url: "https://contributor-website.example.com" -# about: >- -# Short bio. e.g. CNCF Ambassador and maintainer of OpenFeature. Helps teams release faster -# through open standards and feature flagging. Familiar face at KubeCon and Devoxx. - -# optional | narrative context that sets the scene for the whole adventure. Can be 1-3 paragraphs. -# backstory: -# - >- -# Replace with paragraph 1. Describe the fictional world and situation. e.g. The Aletheia -# Institute is running a multi-phase trial. The lab is a Spring Boot service whose one job is -# to record the state of every subject who walks through the protocol. -# - >- -# Replace with paragraph 2. Build the stakes. e.g. It hasn't been working for eight months. -# Every subject through the door has been recorded as "untreated". - -# optional | "What you'll be using" explainer section shown on the adventure overview page. -# context: -# title: "What you'll be using" -# body: -# - >- -# Replace with a paragraph explaining the main technology. e.g. OpenFeature is a vendor-neutral -# standard for feature flags. The reference cloud-native implementation is flagd, which serves -# flag definitions from a JSON file. -# - >- -# Replace with a second paragraph explaining how the adventure uses the technology. - -# optional | uncomment and fill in for reward info: -# rewards: -# deadline: "DD Month YYYY at HH:MM CET" -# eligibility: "Complete all levels and post your solution in the community before the deadline to be eligible." -# tiers: -# - label: "1st place" -# description: "Replace with prize description" -# - label: "Top 3" -# description: "Credly badge to showcase the achievement" -# rankingNote: >- -# Ranking is determined by total points across all levels. Points per level are awarded by -# submission order within the active week (100 for the first valid solution, 95 for the second; -# late submissions still earn 60). -# rankingRulesUrl: "/t/about-the-challenges-category/16" - -levels: -${levelEntries} -`; - -writeFileSync(yamlPath, yamlContent); -console.log(` Created: src/data/adventures/${id}/adventure.yaml`); - -// 3. Patch react-router.config.ts -const configPath = resolve(ROOT, "react-router.config.ts"); -let configContent = readFileSync(configPath, "utf-8"); - -const newRoutes = [ - `"/adventures/${id}"`, - ...levels.map((l) => `"/adventures/${id}/levels/${l}"`), -]; - -// Insert new entries before the closing ] of the prerender array -configContent = configContent.replace( - /(prerender: \[[\s\S]*?)( \],)/, - (match, before, closing) => { - const trimmed = before.trimEnd(); - const needsComma = !trimmed.endsWith(","); - const entries = newRoutes.map((r) => ` ${r},`).join("\n"); - return `${trimmed}${needsComma ? "," : ""}\n${entries}\n${closing}`; - } -); - -if (!configContent.includes(`"/adventures/${id}"`)) { - fail("Failed to patch react-router.config.ts. The file format may have changed. Patch manually."); -} -writeFileSync(configPath, configContent); -console.log(` Patched: react-router.config.ts`); - -// 4. Patch sitemap.xml -const sitemapPath = resolve(ROOT, "public/sitemap.xml"); -let sitemapContent = readFileSync(sitemapPath, "utf-8"); - -const sitemapEntries = [ - ` <url><loc>https://offon.dev/adventures/${id}/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url>`, - ...levels.map( - (l) => - ` <url><loc>https://offon.dev/adventures/${id}/levels/${l}/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url>` - ), -]; - -if (!sitemapContent.includes("</urlset>")) { - fail("Failed to patch sitemap.xml. Could not find </urlset> marker."); -} -sitemapContent = sitemapContent.replace( - "</urlset>", - `${sitemapEntries.join("\n")}\n</urlset>` -); - -writeFileSync(sitemapPath, sitemapContent); -console.log(` Patched: public/sitemap.xml`); - -// Done -const levelList = - levels.length === 1 - ? levels[0] - : levels.slice(0, -1).join(", ") + ", and " + levels[levels.length - 1]; - -console.log(`\n\x1b[32mDone!\x1b[0m Adventure "${title}" scaffolded.\n`); -console.log("Next steps:"); -console.log(` 1. Fill in the TODOs in src/data/adventures/${id}/adventure.yaml`); -console.log(` 2. Update discussion URLs in the YAML and matching *-posts.json files`); -console.log(` 3. Run: npm run generate`); -console.log(` 4. Run: node scripts/refresh-discussions.mjs`); -console.log(` 5. Run: npm run lint && npm test && npm run build && npm run test:e2e`); -console.log(` 6. Commit: git commit -s -m "feat(adventures): add ${title} adventure with ${levelList} levels"`); diff --git a/scripts/new-level.mjs b/scripts/new-level.mjs deleted file mode 100644 index c130b3d6..00000000 --- a/scripts/new-level.mjs +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env node - -/** - * Add a new level to an existing adventure. - * - * Usage: - * node scripts/new-level.mjs --adventure "blind-by-design" --level expert - * - * What it does: - * - Creates src/data/adventures/<adventure>/<level>-posts.json (discussion stub) - * - Adds prerender entry to react-router.config.ts - * - Adds sitemap entry to public/sitemap.xml - * - Sets has_<level>: true in ADVENTURE_CATEGORIES in scripts/refresh-leaderboard.mjs - * - Prints a YAML snippet to paste into the adventure's adventure.yaml levels array - */ - -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, ".."); - -function parseArgs(argv) { - const args = {}; - for (let i = 2; i < argv.length; i++) { - if (argv[i].startsWith("--")) { - const key = argv[i].slice(2); - const value = argv[i + 1]; - if (!value || value.startsWith("--")) { - args[key] = true; - } else { - args[key] = value; - i++; - } - } - } - return args; -} - -function fail(msg) { - console.error(`\x1b[31mError:\x1b[0m ${msg}`); - process.exit(1); -} - -const args = parseArgs(process.argv); - -if (!args.adventure) fail("--adventure is required (e.g. --adventure blind-by-design)"); -if (!args.level) fail("--level is required (e.g. --level expert)"); - -const adventureId = args.adventure; -const levelId = args.level; - -const VALID_LEVELS = ["beginner", "intermediate", "expert"]; -if (!VALID_LEVELS.includes(levelId)) { - fail(`--level must be one of: ${VALID_LEVELS.join(", ")}`); -} - -const ADVENTURES_DIR = resolve(ROOT, "src/data/adventures"); -const adventureDataDir = resolve(ADVENTURES_DIR, adventureId); -const yamlPath = resolve(adventureDataDir, "adventure.yaml"); - -if (!existsSync(yamlPath)) { - fail(`Adventure YAML not found: src/data/adventures/${adventureId}/adventure.yaml\nRun 'npm run new-adventure' first to create the adventure.`); -} - -const jsonPath = resolve(adventureDataDir, `${levelId}-posts.json`); -if (existsSync(jsonPath)) { - fail(`Level JSON already exists: src/data/adventures/${adventureId}/${levelId}-posts.json`); -} - -const difficultyMap = { - beginner: "Beginner", - intermediate: "Intermediate", - expert: "Expert", -}; -const difficulty = difficultyMap[levelId] || "Beginner"; - -// 1. Create discussion JSON stub -mkdirSync(adventureDataDir, { recursive: true }); -writeFileSync( - jsonPath, - JSON.stringify({ discussionUrl: "TODO: Add Discourse topic URL" }, null, 2) + "\n" -); -console.log(` Created: src/data/adventures/${adventureId}/${levelId}-posts.json`); - -// 2. Patch react-router.config.ts -const configPath = resolve(ROOT, "react-router.config.ts"); -let configContent = readFileSync(configPath, "utf-8"); - -const newRoute = `"/adventures/${adventureId}/levels/${levelId}"`; - -if (configContent.includes(newRoute)) { - console.log(` Skipped: react-router.config.ts (entry already exists)`); -} else { - configContent = configContent.replace( - /(prerender: \[[\s\S]*?)( \],)/, - (match, before, closing) => { - const trimmed = before.trimEnd(); - const needsComma = !trimmed.endsWith(","); - return `${trimmed}${needsComma ? "," : ""}\n ${newRoute},\n${closing}`; - } - ); - if (!configContent.includes(newRoute)) { - fail("Failed to patch react-router.config.ts. The file format may have changed. Patch manually."); - } - writeFileSync(configPath, configContent); - console.log(` Patched: react-router.config.ts`); -} - -// 3. Patch sitemap.xml -const sitemapPath = resolve(ROOT, "public/sitemap.xml"); -let sitemapContent = readFileSync(sitemapPath, "utf-8"); - -const sitemapEntry = ` <url><loc>https://offon.dev/adventures/${adventureId}/levels/${levelId}/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url>`; - -if (sitemapContent.includes(`/adventures/${adventureId}/levels/${levelId}/`)) { - console.log(` Skipped: public/sitemap.xml (entry already exists)`); -} else { - if (!sitemapContent.includes("</urlset>")) { - fail("Failed to patch sitemap.xml. Could not find </urlset> marker."); - } - sitemapContent = sitemapContent.replace("</urlset>", `${sitemapEntry}\n</urlset>`); - writeFileSync(sitemapPath, sitemapContent); - console.log(` Patched: public/sitemap.xml`); -} - -// 4. Patch ADVENTURE_CATEGORIES in scripts/refresh-leaderboard.mjs -const leaderboardScriptPath = resolve(ROOT, "scripts/refresh-leaderboard.mjs"); -const levelFlag = `has_${levelId}`; -const leaderboardContent = readFileSync(leaderboardScriptPath, "utf-8"); -const escapedId = adventureId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -const flagFalseRegex = new RegExp(`("${escapedId}"[^\\n]*\\b${levelFlag}:\\s*)false`); - -if (flagFalseRegex.test(leaderboardContent)) { - writeFileSync(leaderboardScriptPath, leaderboardContent.replace(flagFalseRegex, "$1true")); - console.log(` Patched: scripts/refresh-leaderboard.mjs (${levelFlag}: true)`); -} else if (new RegExp(`"${escapedId}"`).test(leaderboardContent)) { - console.log(` Skipped: scripts/refresh-leaderboard.mjs (${levelFlag} already true)`); -} else { - console.warn(` Warning: "${adventureId}" not found in ADVENTURE_CATEGORIES in scripts/refresh-leaderboard.mjs. Add it manually.`); -} - -// 5. Print the YAML snippet to paste -console.log(`\n\x1b[32mDone!\x1b[0m Level "${levelId}" config added for "${adventureId}".\n`); -console.log("Paste this into the levels array in src/data/adventures/" + adventureId + "/adventure.yaml:\n"); -console.log(` - id: ${levelId} - # required | e.g. "Stand Up the Lab" / "Outcome by Cohort" / "Lights On" - name: "TODO: Replace with level display name" - difficulty: ${difficulty} - topics: - # required | technology and tool names shown as pill tags on the level card. - - "TODO: Replace with technology name" - - "TODO: Replace with another technology name" - learnings: - # required | full sentences describing concrete skills or insights gained. Use >- for prose with colons. - - >- - TODO: How the client and provider work together: the SDK is provider-agnostic and plugs in via dependency only - - >- - TODO: Why hot-reload matters operationally: editing the flag file flips behaviour on the next request with no redeploy - devcontainerPath: ".devcontainer/TODO/devcontainer.json" # required - discussionUrl: "/t/TODO" # required - intro: - # required | 1-2 sentences: what the player will wire up and what they will prove works. - - >- - TODO: Wire the SDK into the service so flag evaluations are resolved by a sidecar running alongside your Codespace. - Prove that editing the flag file flips the response on the next request without restarting anything. - objective: - # required | verifiable outcomes a player can check with a command. - - >- - TODO: curl http://localhost:8080/ returns a value resolved from the flag file, not the hard-coded fallback - - >- - TODO: Editing the flag file and re-running curl returns the new value without restarting the service - # optional | uncomment to describe who this level is aimed at and what prior knowledge helps. - # audience: >- - # Best suited for platform engineers and developers new to feature flagging. Familiarity with basic - # Java and Spring Boot helps but no prior SDK experience is needed. - # optional | narrative context that sets the scene for this specific level. - # backstory: - # - >- - # The service is returning a hard-coded response. The SDK was integrated last quarter but the provider - # was never registered, so every request falls back to the default. Complete the wiring so the service - # reads from the flag file instead. - # optional | describe the technical setup (services, ports, how they connect). - # architecture: - # - >- - # Two containers run side-by-side: the app on http://localhost:8080 and a sidecar on port 8013. - # - >- - # Edit the flag file through the IDE; the file watcher picks up changes within about a second. - toolbox: - # required | list the CLI tools and services available in the Codespace. Add a url field for external docs. - - name: "TODO: ./run.sh" - description: "TODO: Starts the service; also available via F5 in VS Code" - - name: "curl" - description: "TODO: sends requests to http://localhost:8080/ to confirm flags are being evaluated" - url: "https://curl.se/" - howToPlay: - # required | walk the player from the broken state to a working solution, one titled step at a time. - # Use fenced code blocks for commands. First step: confirm the broken state. Last step: run the verifier. - - title: "Confirm the Broken State" - body: | - TODO: Start the service and confirm the hard-coded fallback is returned: - - \`\`\`sh - ./run.sh - curl http://localhost:8080/ - # returns the fallback — this is the broken state - \`\`\` - - title: "TODO: Replace with main fix step title" - body: >- - TODO: Replace with instructions for the main fix. What file to open, what to add or change. - - title: "Verify the Fix" - body: | - TODO: Edit the flag file and re-run curl without restarting anything: - - \`\`\`sh - curl http://localhost:8080/ - # now returns the flag-resolved value - \`\`\` - helpfulLinks: - # optional | reference docs the player will need. label is the link text; url must be a full https:// URL. - - label: "TODO: SDK documentation" - url: https://example.com/docs - - label: "TODO: Flag definition reference" - url: https://example.com/flags - verification: - # required | keep the defaults unless the verification script name or message differs. - command: "./verify.sh" - description: "Once you think you've solved the challenge, run the verification script."`); -console.log(`\nNext steps:`); -console.log(` 1. Paste the snippet above into src/data/adventures/${adventureId}/adventure.yaml`); -console.log(` 2. Fill in the TODOs`); -console.log(` 3. Add the level route to src/routes.ts`); -console.log(` 4. Update the discussionUrl in the YAML and src/data/adventures/${adventureId}/${levelId}-posts.json`); -console.log(` 5. Run: npm run generate`); -console.log(` 6. Run: node scripts/refresh-discussions.mjs`); -console.log(` 7. Run: node scripts/refresh-leaderboard.mjs (requires DISCOURSE_API_KEY)`); -console.log(` 8. Add the level URL to e2e/smoke.spec.ts, src/test/seo.test.ts, and src/test/prerender.test.ts`); -console.log(` 9. Update the routes table in README.md`); -console.log(` 10. Run: npm run lint && npm test && npm run build && npm run test:e2e`); diff --git a/scripts/sync-adventure.mjs b/scripts/sync-adventure.mjs index bca8b921..caaa7ac6 100644 --- a/scripts/sync-adventure.mjs +++ b/scripts/sync-adventure.mjs @@ -15,11 +15,15 @@ * adventure-mode - "create" or "update" */ -import { execSync } from "node:child_process"; +import { exec } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { LEVEL_DIFFICULTY_BY_ID, LEVEL_DIFFICULTY_BY_EMOJI, LEVEL_ORDER } from "./lib/level-constants.mjs"; + +const execAsync = promisify(exec); const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, ".."); @@ -33,7 +37,6 @@ const VERIFICATION_STUB = { "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}`); @@ -55,24 +58,23 @@ function deriveSlug(folderName) { return folderName.replace(/^\d+-/, ""); } -function ghApi(endpoint) { +async function ghApi(endpoint) { try { - return JSON.parse( - execSync(`gh api "${endpoint}"`, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }) - ); + const { stdout } = await execAsync(`gh api "${endpoint}"`, { encoding: "utf8" }); + return JSON.parse(stdout); } catch { return null; } } -function fetchYaml(repo, filePath) { - const data = ghApi(`repos/${repo}/contents/${filePath}`); +async function fetchYaml(repo, filePath) { + const data = await 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}`); +async function listDir(repo, dirPath) { + const data = await ghApi(`repos/${repo}/contents/${dirPath}`); return Array.isArray(data) ? data.map((f) => f.name) : []; } @@ -97,31 +99,53 @@ function mergeLevels(existing, incoming) { ); } -function main() { +async function main() { const url = process.env.ADVENTURE_URL; if (!url) fail("ADVENTURE_URL environment variable is required"); + const levelsToSync = (process.env.LEVELS_TO_SYNC || "") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + const { repo, path: adventurePath } = parseAdventureUrl(url); const folderName = adventurePath.split("/").pop(); const slug = deriveSlug(folderName); - console.log(`Syncing: ${repo}/${adventurePath} → ${slug}`); + const syncLabel = levelsToSync.length > 0 ? ` (levels: ${levelsToSync.join(", ")})` : " (all levels)"; + console.log(`Syncing: ${repo}/${adventurePath} → ${slug}${syncLabel}`); - const indexData = fetchYaml(repo, `${adventurePath}/docs/index.yaml`); + // Fetch index.yaml and docs directory listing in parallel. + const [indexData, docsFiles] = await Promise.all([ + fetchYaml(repo, `${adventurePath}/docs/index.yaml`), + listDir(repo, `${adventurePath}/docs`), + ]); 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}`); + // Fetch all level YAMLs in parallel. + const levelResults = await Promise.all( + levelFileNames.map((fileName) => fetchYaml(repo, `${adventurePath}/docs/${fileName}`)) + ); + const allFetchedLevels = []; + for (let i = 0; i < levelFileNames.length; i++) { + const raw = levelResults[i]; if (raw) { - incomingLevels.push(buildLevel(raw, adventureTags)); - console.log(` Fetched level: ${fileName}`); + allFetchedLevels.push(buildLevel(raw, adventureTags)); + console.log(` Fetched level: ${levelFileNames[i]}`); + } + } + + // Validate that every requested level ID was actually found in the challenges repo. + if (levelsToSync.length > 0) { + const fetchedIds = new Set(allFetchedLevels.map((l) => l.level)); + const missing = levelsToSync.filter((id) => !fetchedIds.has(id)); + if (missing.length > 0) { + fail(`Requested level(s) not found in the challenges repo: ${missing.join(", ")}. Available: ${[...fetchedIds].join(", ")}`); } } @@ -131,6 +155,29 @@ function main() { const mode = existing ? "update" : "create"; console.log(`Mode: ${mode}`); + // Levels already live in the adventure — never demoted regardless of levelsToSync. + const existingLiveIds = new Set((existing?.levels || []).map((l) => l.level)); + + // Active = already live OR explicitly in levelsToSync (or all if levelsToSync is empty). + const activeLevels = allFetchedLevels.filter((l) => { + if (levelsToSync.length === 0) return true; + return existingLiveIds.has(l.level) || levelsToSync.includes(l.level); + }); + + const activeLevelIds = new Set(activeLevels.map((l) => l.level)); + + // Upcoming = fetched from challenges repo but not yet live and not being promoted now. + const upcomingLevels = allFetchedLevels + .filter((l) => !existingLiveIds.has(l.level) && !activeLevelIds.has(l.level)) + .map((l) => ({ + name: l.name || l.title, + difficulty: l.difficulty || LEVEL_DIFFICULTY_BY_EMOJI[l.emoji] || LEVEL_DIFFICULTY_BY_ID[l.level], + })); + + if (upcomingLevels.length > 0) { + console.log(` Upcoming (not live yet): ${upcomingLevels.map((u) => u.difficulty).join(", ")}`); + } + // Build the combined adventure object using challenges repo field names. // The generator accepts all aliases (name/title, emoji → icon, etc.). const adventure = { @@ -146,15 +193,16 @@ function main() { ...(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), + ...(upcomingLevels.length > 0 && { upcoming_levels: upcomingLevels }), + levels: mergeLevels(existing?.levels, activeLevels), }; 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) { + // Create discussion JSON stubs for newly active levels only. + for (const level of activeLevels) { const stubPath = resolve(adventureDir, `${level.level}-posts.json`); if (!existsSync(stubPath)) { writeFileSync( @@ -166,14 +214,21 @@ function main() { } const adventureName = indexData.title || indexData.name || slug; - const levelIds = incomingLevels.map((l) => l.level).join(","); + // Report only the newly promoted levels so the PR title and checklist are accurate. + const newLevelIds = activeLevels + .filter((l) => !existingLiveIds.has(l.level)) + .map((l) => l.level) + .join(",") || activeLevels.map((l) => l.level).join(","); writeFileSync("/tmp/adventure-slug", slug); writeFileSync("/tmp/adventure-name", adventureName); - writeFileSync("/tmp/adventure-levels", levelIds); + writeFileSync("/tmp/adventure-levels", newLevelIds); writeFileSync("/tmp/adventure-mode", mode); - console.log(`\nDone: ${adventureName} (${levelIds})`); + console.log(`\nDone: ${adventureName} (live: ${activeLevels.map((l) => l.level).join(", ")}${upcomingLevels.length > 0 ? ` | upcoming: ${upcomingLevels.map((u) => u.difficulty).join(", ")}` : ""})`); } -main(); +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/data/adventures/lex-imperfecta.generated.ts b/src/data/adventures/lex-imperfecta.generated.ts deleted file mode 100644 index f2bdd758..00000000 --- a/src/data/adventures/lex-imperfecta.generated.ts +++ /dev/null @@ -1,136 +0,0 @@ -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/pages/ChallengeDetail.tsx b/src/pages/ChallengeDetail.tsx index ce65cde2..826d8493 100644 --- a/src/pages/ChallengeDetail.tsx +++ b/src/pages/ChallengeDetail.tsx @@ -20,7 +20,7 @@ import { DiscussionSection } from "@/components/DiscussionSection"; import { CommunitySidebar } from "@/components/CommunitySidebar"; import { OtherLevelsCard } from "@/components/OtherLevelsCard"; import { RewardsCard } from "@/components/RewardsCard"; -import { SITE_URL, BRAND_NAME, COMMUNITY_DISPLAY_NAME } from "@/data/constants"; +import { SITE_URL, BRAND_NAME, COMMUNITY_DISPLAY_NAME, COMMUNITY_URL } from "@/data/constants"; import { buildPageMeta } from "@/lib/meta"; import { isDeadlinePast } from "@/lib/utils"; @@ -213,7 +213,7 @@ const StructuredLayout = ({ adventure, level, rewardsBelowFold }: StructuredLayo <span> Share your solutions in the{" "} <a - href={level.discussionUrl} + href={level.discussionUrl || COMMUNITY_URL} target="_blank" rel="noopener noreferrer" className="docs-ext-link font-medium" From 8313c7b4c95118cee0aa3811968ac180cab5cf7a Mon Sep 17 00:00:00 2001 From: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com> Date: Sun, 31 May 2026 15:51:06 +0200 Subject: [PATCH 3/3] feat: auto-patch sitemap, routes, prerender, and leaderboard from adventure YAML Generator now owns drift-prone arrays across consumer files via regions: - public/sitemap.xml: adventure URLs and challenge tag URLs - react-router.config.ts: prerender entries for adventures and tags - e2e/smoke.spec.ts and src/test/seo.test.ts: ROUTES for adventures and tags - src/test/prerender.test.ts: pages array for adventures - scripts/refresh-leaderboard.mjs: ADVENTURE_CATEGORIES Schema adds community_category_id so generator can emit categoryId without manual edits; missing values emit a TODO stub. sync-adventure.mjs strips architecture_diagram (added manually if SVG exists), preserves community_category_id on re-sync, and surfaces gh API errors. sync-adventure.yml uses --force-with-lease, --state open, tightens URL regex to off-on-dev/open-source-challenges, and restructures PR checklist. validate-adventures.yml fixes grep pattern (- id: -> - level:) that was silently skipping all per-level validation, and updates stale npm reference. Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com> --- .github/workflows/sync-adventure.yml | 40 +-- .github/workflows/validate-adventures.yml | 6 +- e2e/smoke.spec.ts | 26 +- public/sitemap.xml | 22 +- react-router.config.ts | 26 +- schemas/adventure.schema.json | 4 + scripts/generate-adventures.mjs | 227 ++++++++++++++++++ scripts/refresh-leaderboard.mjs | 17 +- scripts/sync-adventure.mjs | 14 +- src/components/Footer.tsx | 4 +- .../adventures/blind-by-design/adventure.yaml | 1 + .../blind-by-design/leaderboard.json | 2 +- .../building-cloudhaven/adventure.yaml | 1 + .../building-cloudhaven/leaderboard.json | 8 +- .../echoes-lost-in-orbit/adventure.yaml | 1 + .../echoes-lost-in-orbit/leaderboard.json | 30 +-- .../the-ai-observatory/adventure.yaml | 1 + .../the-ai-observatory/leaderboard.json | 8 +- src/data/constants.ts | 2 +- src/test/prerender.test.ts | 62 ++--- src/test/seo.test.ts | 26 +- 21 files changed, 398 insertions(+), 130 deletions(-) diff --git a/.github/workflows/sync-adventure.yml b/.github/workflows/sync-adventure.yml index 76cac875..486408ef 100644 --- a/.github/workflows/sync-adventure.yml +++ b/.github/workflows/sync-adventure.yml @@ -42,8 +42,8 @@ jobs: 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" + if ! echo "$ADVENTURE_URL" | grep -qE '^https://github\.com/off-on-dev/open-source-challenges/tree/[^/]+/adventures/'; then + echo "Error: URL must point to an adventure folder under adventures/ in off-on-dev/open-source-challenges" echo "Example: https://github.com/off-on-dev/open-source-challenges/tree/main/adventures/05-lex-imperfecta" exit 1 fi @@ -77,11 +77,18 @@ jobs: src/data/adventures/${SLUG}/ \ src/data/adventures/${SLUG}.generated.ts \ src/data/adventures/index.ts \ - src/data/adventures/summaries.ts + src/data/adventures/summaries.ts \ + src/data/adventures/types.ts \ + public/sitemap.xml \ + react-router.config.ts \ + scripts/refresh-leaderboard.mjs \ + e2e/smoke.spec.ts \ + src/test/seo.test.ts \ + src/test/prerender.test.ts git commit -m "feat: sync adventure ${SLUG} from challenges repo (${MODE})" - git push origin --delete "$BRANCH" 2>/dev/null || true - git push origin "$BRANCH" + # Force-with-lease keeps PR identity and review history when re-syncing. + git push --force-with-lease origin "$BRANCH" if [ "$MODE" = "update" ]; then PR_TITLE="feat(adventure): add levels to ${NAME} — ${LEVELS}" @@ -104,26 +111,29 @@ jobs: printf -- ' about: "One sentence bio."\n' printf -- ' ```\n' printf -- '- [ ] Confirm `month:` is correct for the planned release\n' + printf -- '- [ ] Set `community_category_id:` in `src/data/adventures/%s/adventure.yaml` (look up at https://community.offon.dev/categories.json), then run `npm run generate`\n' "$SLUG" 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 `<title>`\n' - printf -- '- [ ] Update routes table in `README.md`\n\n' + printf '### Manual steps (only if needed)\n\n' + printf -- '- [ ] If any level uses an SVG `architecture_diagram`, add the SVG to `src/assets/diagrams/` and add `architecture_diagram: <file>.svg` back to that level in `adventure.yaml` (sync strips it)\n' + printf -- '- [ ] Run `node scripts/refresh-leaderboard.mjs` after `community_category_id` is set\n' + printf -- '- [ ] Run `node scripts/refresh-discussions.mjs` after `discussionUrl` values are set\n\n' + printf '### Auto-generated (do not edit by hand)\n\n' + printf 'The following are kept in sync with `adventure.yaml` by `npm run generate` (prebuild hook):\n' + printf -- '- `public/sitemap.xml` (GENERATED:adventures region)\n' + printf -- '- `react-router.config.ts` prerender (GENERATED:adventures region)\n' + printf -- '- `e2e/smoke.spec.ts` and `src/test/seo.test.ts` route arrays\n' + printf -- '- `src/test/prerender.test.ts` pages array\n' + printf -- '- `scripts/refresh-leaderboard.mjs` ADVENTURE_CATEGORIES\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') + EXISTING_PR=$(gh pr list --head "$BRANCH" --state open --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 diff --git a/.github/workflows/validate-adventures.yml b/.github/workflows/validate-adventures.yml index d836a931..034e29b3 100644 --- a/.github/workflows/validate-adventures.yml +++ b/.github/workflows/validate-adventures.yml @@ -68,8 +68,8 @@ jobs: ERRORS=$((ERRORS + 1)) fi - # Extract level IDs from the YAML file - level_ids=$(grep -E '^\s+- id: ' "$adventure_dir/adventure.yaml" | sed 's/.*- id: //' | tr -d '"' || true) + # Extract level IDs from the YAML file (field renamed from `id:` to `level:` in the sync migration) + level_ids=$(grep -E '^\s+- level: ' "$adventure_dir/adventure.yaml" | sed 's/.*- level: //' | tr -d '"' || true) # Check adventure landing page in prerender if ! grep -q "\"/adventures/$adventure_id\"" react-router.config.ts; then @@ -109,7 +109,7 @@ jobs: if [[ $ERRORS -gt 0 ]]; then echo "" - echo "❌ Found $ERRORS consistency error(s). Run 'npm run new-adventure' to scaffold or 'npm run generate' to regenerate." + echo "❌ Found $ERRORS consistency error(s). Run 'npm run generate' to regenerate route/sitemap/prerender regions, or re-run the Sync Adventure workflow." exit 1 else echo "" diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index eaf0294a..6232411a 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -14,25 +14,29 @@ const ROUTES: RouteSpec[] = [ { path: "/accessibility", title: /Accessibility Statement/ }, { path: "/404", title: /Page Not Found/ }, { path: "/adventures", title: /Adventures - Hands-on open source challenges/ }, - { path: "/adventures/echoes-lost-in-orbit", title: /Echoes Lost in Orbit/ }, + // GENERATED:adventures + { path: "/adventures/blind-by-design", title: /Blind by Design/ }, + { path: "/adventures/blind-by-design/levels/beginner", title: /Stand up the Lab/ }, + { path: "/adventures/blind-by-design/levels/intermediate", title: /Outcome by Cohort/ }, + { path: "/adventures/blind-by-design/levels/expert", title: /Read the Chart/ }, { path: "/adventures/building-cloudhaven", title: /Building CloudHaven/ }, - { path: "/adventures/the-ai-observatory", title: /The AI Observatory/ }, - { path: "/adventures/echoes-lost-in-orbit/levels/beginner", title: /Broken Echoes/ }, - { path: "/adventures/echoes-lost-in-orbit/levels/intermediate", title: /The Silent Canary/ }, - { path: "/adventures/echoes-lost-in-orbit/levels/expert", title: /Hyperspace Operations/ }, { path: "/adventures/building-cloudhaven/levels/beginner", title: /The Foundation Stones/ }, { path: "/adventures/building-cloudhaven/levels/intermediate", title: /The Modular Metropolis/ }, { path: "/adventures/building-cloudhaven/levels/expert", title: /The Guardian Protocols/ }, + { path: "/adventures/echoes-lost-in-orbit", title: /Echoes Lost in Orbit/ }, + { path: "/adventures/echoes-lost-in-orbit/levels/beginner", title: /Broken Echoes/ }, + { path: "/adventures/echoes-lost-in-orbit/levels/intermediate", title: /The Silent Canary/ }, + { path: "/adventures/echoes-lost-in-orbit/levels/expert", title: /Hyperspace Operations & Transport/ }, + { path: "/adventures/the-ai-observatory", title: /The AI Observatory/ }, { path: "/adventures/the-ai-observatory/levels/beginner", title: /Calibrating the Lens/ }, { path: "/adventures/the-ai-observatory/levels/intermediate", title: /The Distracted Pilot/ }, { path: "/adventures/the-ai-observatory/levels/expert", title: /The Noise Filter/ }, - { path: "/adventures/blind-by-design", title: /Blind by Design/ }, - { path: "/adventures/blind-by-design/levels/beginner", title: /Stand up the Lab/ }, - { path: "/adventures/blind-by-design/levels/intermediate", title: /Outcome by Cohort/ }, - { path: "/adventures/blind-by-design/levels/expert", title: /Read the Chart/ }, + // /GENERATED:adventures { path: "/challenges", title: /Open Source Challenges/ }, + // GENERATED:challenge-tags { path: "/challenges/argo-cd", title: /Argo CD Challenges/ }, { path: "/challenges/argo-rollouts", title: /Argo Rollouts Challenges/ }, + { path: "/challenges/flagd", title: /flagd Challenges/ }, { path: "/challenges/github-actions", title: /GitHub Actions Challenges/ }, { path: "/challenges/grafana", title: /Grafana Challenges/ }, { path: "/challenges/jaeger", title: /Jaeger Challenges/ }, @@ -41,14 +45,14 @@ const ROUTES: RouteSpec[] = [ { path: "/challenges/openllmetry", title: /OpenLLMetry Challenges/ }, { path: "/challenges/opentelemetry", title: /OpenTelemetry Challenges/ }, { path: "/challenges/opentofu", title: /OpenTofu Challenges/ }, - { path: "/challenges/promql", title: /PromQL Challenges/ }, { path: "/challenges/prometheus", title: /Prometheus Challenges/ }, + { path: "/challenges/promql", title: /PromQL Challenges/ }, { path: "/challenges/python", title: /Python Challenges/ }, { path: "/challenges/spring-boot", title: /Spring Boot Challenges/ }, { path: "/challenges/tdd", title: /TDD Challenges/ }, { path: "/challenges/terraform", title: /Terraform Challenges/ }, { path: "/challenges/trivy", title: /Trivy Challenges/ }, - { path: "/challenges/flagd", title: /flagd Challenges/ }, + // /GENERATED:challenge-tags ]; test.describe("every prerendered route", () => { diff --git a/public/sitemap.xml b/public/sitemap.xml index f77d214d..4f581d6a 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -7,24 +7,26 @@ <url><loc>https://offon.dev/handbook/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> <url><loc>https://offon.dev/accessibility/</loc><changefreq>yearly</changefreq><priority>0.5</priority></url> <url><loc>https://offon.dev/privacy/</loc><changefreq>yearly</changefreq><priority>0.5</priority></url> - <url><loc>https://offon.dev/adventures/echoes-lost-in-orbit/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> + <url><loc>https://offon.dev/adventures/blind-by-design/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> + <url><loc>https://offon.dev/adventures/blind-by-design/levels/beginner/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> + <url><loc>https://offon.dev/adventures/blind-by-design/levels/intermediate/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> + <url><loc>https://offon.dev/adventures/blind-by-design/levels/expert/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> <url><loc>https://offon.dev/adventures/building-cloudhaven/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/the-ai-observatory/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/echoes-lost-in-orbit/levels/beginner/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/echoes-lost-in-orbit/levels/intermediate/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/echoes-lost-in-orbit/levels/expert/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> <url><loc>https://offon.dev/adventures/building-cloudhaven/levels/beginner/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> <url><loc>https://offon.dev/adventures/building-cloudhaven/levels/intermediate/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> <url><loc>https://offon.dev/adventures/building-cloudhaven/levels/expert/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> + <url><loc>https://offon.dev/adventures/echoes-lost-in-orbit/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> + <url><loc>https://offon.dev/adventures/echoes-lost-in-orbit/levels/beginner/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> + <url><loc>https://offon.dev/adventures/echoes-lost-in-orbit/levels/intermediate/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> + <url><loc>https://offon.dev/adventures/echoes-lost-in-orbit/levels/expert/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> + <url><loc>https://offon.dev/adventures/the-ai-observatory/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> <url><loc>https://offon.dev/adventures/the-ai-observatory/levels/beginner/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> <url><loc>https://offon.dev/adventures/the-ai-observatory/levels/intermediate/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> <url><loc>https://offon.dev/adventures/the-ai-observatory/levels/expert/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/blind-by-design/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/blind-by-design/levels/beginner/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/blind-by-design/levels/intermediate/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> <url><loc>https://offon.dev/challenges/</loc><changefreq>weekly</changefreq><priority>0.9</priority></url> <url><loc>https://offon.dev/challenges/argo-cd/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> <url><loc>https://offon.dev/challenges/argo-rollouts/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> + <url><loc>https://offon.dev/challenges/flagd/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> <url><loc>https://offon.dev/challenges/github-actions/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> <url><loc>https://offon.dev/challenges/grafana/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> <url><loc>https://offon.dev/challenges/jaeger/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> @@ -33,13 +35,11 @@ <url><loc>https://offon.dev/challenges/openllmetry/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> <url><loc>https://offon.dev/challenges/opentelemetry/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> <url><loc>https://offon.dev/challenges/opentofu/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/promql/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> <url><loc>https://offon.dev/challenges/prometheus/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> + <url><loc>https://offon.dev/challenges/promql/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> <url><loc>https://offon.dev/challenges/python/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> <url><loc>https://offon.dev/challenges/spring-boot/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> <url><loc>https://offon.dev/challenges/tdd/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> <url><loc>https://offon.dev/challenges/terraform/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> <url><loc>https://offon.dev/challenges/trivy/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/flagd/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/adventures/blind-by-design/levels/expert/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> </urlset> diff --git a/react-router.config.ts b/react-router.config.ts index 3ccded1a..819ddecc 100644 --- a/react-router.config.ts +++ b/react-router.config.ts @@ -14,24 +14,29 @@ export default { "/handbook", "/privacy", "/accessibility", - "/adventures/echoes-lost-in-orbit", + // GENERATED:adventures + "/adventures/blind-by-design", + "/adventures/blind-by-design/levels/beginner", + "/adventures/blind-by-design/levels/intermediate", + "/adventures/blind-by-design/levels/expert", "/adventures/building-cloudhaven", - "/adventures/the-ai-observatory", - "/adventures/echoes-lost-in-orbit/levels/beginner", - "/adventures/echoes-lost-in-orbit/levels/intermediate", - "/adventures/echoes-lost-in-orbit/levels/expert", "/adventures/building-cloudhaven/levels/beginner", "/adventures/building-cloudhaven/levels/intermediate", "/adventures/building-cloudhaven/levels/expert", + "/adventures/echoes-lost-in-orbit", + "/adventures/echoes-lost-in-orbit/levels/beginner", + "/adventures/echoes-lost-in-orbit/levels/intermediate", + "/adventures/echoes-lost-in-orbit/levels/expert", + "/adventures/the-ai-observatory", "/adventures/the-ai-observatory/levels/beginner", "/adventures/the-ai-observatory/levels/intermediate", "/adventures/the-ai-observatory/levels/expert", - "/adventures/blind-by-design", - "/adventures/blind-by-design/levels/beginner", - "/adventures/blind-by-design/levels/intermediate", + // /GENERATED:adventures "/challenges", + // GENERATED:challenge-tags "/challenges/argo-cd", "/challenges/argo-rollouts", + "/challenges/flagd", "/challenges/github-actions", "/challenges/grafana", "/challenges/jaeger", @@ -40,14 +45,13 @@ export default { "/challenges/openllmetry", "/challenges/opentelemetry", "/challenges/opentofu", - "/challenges/promql", "/challenges/prometheus", + "/challenges/promql", "/challenges/python", "/challenges/spring-boot", "/challenges/tdd", "/challenges/terraform", "/challenges/trivy", - "/challenges/flagd", - "/adventures/blind-by-design/levels/expert", + // /GENERATED:challenge-tags ], } satisfies Config; diff --git a/schemas/adventure.schema.json b/schemas/adventure.schema.json index f92bd7a8..9512aff5 100644 --- a/schemas/adventure.schema.json +++ b/schemas/adventure.schema.json @@ -46,6 +46,10 @@ "contributor": { "$ref": "#/$defs/contributor" }, + "community_category_id": { + "type": "integer", + "description": "Discourse category ID for the leaderboard query. Look it up at https://community.offon.dev/categories.json. Drives ADVENTURE_CATEGORIES in scripts/refresh-leaderboard.mjs." + }, "backstory": { "type": "array", "items": { "type": "string" }, diff --git a/scripts/generate-adventures.mjs b/scripts/generate-adventures.mjs index 7626e10f..4abe0b1b 100644 --- a/scripts/generate-adventures.mjs +++ b/scripts/generate-adventures.mjs @@ -559,6 +559,230 @@ function generateIndexTs(adventures) { return lines.join("\n"); } +// --- Region patching --- + +/** + * Each consumer file has a region marked by `GENERATED:adventures` / `/GENERATED:adventures` + * comments. The body between those markers is regenerated from the YAML on every build. + * Hand-edits to entries inside the region will be overwritten. Add manual entries OUTSIDE + * the markers. + */ +function escapeRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function replaceRegion(filePath, openMarker, closeMarker, body) { + if (!existsSync(filePath)) fail(`Region target file not found: ${filePath}`); + const content = readFileSync(filePath, "utf-8"); + const re = new RegExp( + `(${escapeRegex(openMarker)})[\\s\\S]*?(${escapeRegex(closeMarker)})` + ); + if (!re.test(content)) { + fail(`Region markers not found in ${filePath}. Expected "${openMarker}" ... "${closeMarker}".`); + } + const next = content.replace(re, `$1\n${body}$2`); + if (next !== content) { + writeFileSync(filePath, next); + console.log(` Patched region: ${filePath.replace(ROOT + "/", "")}`); + } +} + +/** Build the body for a region as one block of text. Body must include a trailing newline. */ +function buildSitemapBody(adventures) { + const lines = []; + for (const a of adventures) { + lines.push(` <url><loc>https://offon.dev/adventures/${a.slug}/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url>`); + for (const l of a.levels) { + lines.push(` <url><loc>https://offon.dev/adventures/${a.slug}/levels/${l.level}/</loc><changefreq>monthly</changefreq><priority>0.8</priority></url>`); + } + } + return lines.join("\n") + "\n "; +} + +function buildPrerenderBody(adventures) { + const lines = []; + for (const a of adventures) { + lines.push(` "/adventures/${a.slug}",`); + for (const l of a.levels) { + lines.push(` "/adventures/${a.slug}/levels/${l.level}",`); + } + } + return lines.join("\n") + "\n "; +} + +function buildSeoRoutesBody(adventures) { + const lines = []; + for (const a of adventures) { + lines.push(` "/adventures/${a.slug}",`); + for (const l of a.levels) { + lines.push(` "/adventures/${a.slug}/levels/${l.level}",`); + } + } + return lines.join("\n") + "\n "; +} + +function buildSmokeRoutesBody(adventures) { + const lines = []; + for (const a of adventures) { + const { title } = normalizeAdventureFields(a); + lines.push(` { path: "/adventures/${a.slug}", title: /${escapeRegex(title)}/ },`); + for (const l of a.levels) { + const { name } = normalizeLevelFields(l); + lines.push(` { path: "/adventures/${a.slug}/levels/${l.level}", title: /${escapeRegex(name)}/ },`); + } + } + return lines.join("\n") + "\n "; +} + +function buildPrerenderTestBody(adventures) { + // The "contains" check matches the raw HTML, so HTML entities must be encoded here. + const htmlEncode = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); + const lines = []; + for (const a of adventures) { + const { title } = normalizeAdventureFields(a); + lines.push(` {`); + lines.push(` file: "adventures/${a.slug}/index.html",`); + lines.push(` check: { type: "contains", value: "${escapeDoubleQuoted(htmlEncode(title))}" },`); + lines.push(` },`); + for (const l of a.levels) { + const { name } = normalizeLevelFields(l); + lines.push(` {`); + lines.push(` file: "adventures/${a.slug}/levels/${l.level}/index.html",`); + lines.push(` check: { type: "contains", value: "${escapeDoubleQuoted(htmlEncode(name))}" },`); + lines.push(` },`); + } + } + return lines.join("\n") + "\n "; +} + +function buildLeaderboardCategoriesBody(adventures) { + const lines = []; + // Align colons by padding the key to a stable width. + const maxKeyLen = Math.max(...adventures.map((a) => a.slug.length)); + for (const a of adventures) { + const has_beginner = a.levels.some((l) => l.level === "beginner"); + const has_intermediate = a.levels.some((l) => l.level === "intermediate"); + const has_expert = a.levels.some((l) => l.level === "expert"); + const key = `"${a.slug}":`.padEnd(maxKeyLen + 3); + const todo = a.community_category_id === undefined + ? " // TODO: set categoryId — look up at https://community.offon.dev/categories.json" + : ""; + const categoryId = a.community_category_id ?? 0; + lines.push(` ${key} { categoryId: ${categoryId}, has_beginner: ${has_beginner}, has_intermediate: ${has_intermediate}, has_expert: ${has_expert}, has_single: false },${todo}`); + } + return lines.join("\n") + "\n "; +} + +/** Mirrors `tagToSlug` in the generated index.ts so consumer files use identical slugs. */ +function tagToSlug(tag) { + return tag.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); +} + +function collectAllTags(adventures) { + const set = new Set(); + for (const a of adventures) { + for (const t of a.tags || []) set.add(t); + } + return [...set].sort((x, y) => x.localeCompare(y)); +} + +function buildSitemapTagsBody(tags) { + const lines = tags.map( + (t) => + ` <url><loc>https://offon.dev/challenges/${tagToSlug(t)}/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>`, + ); + return lines.join("\n") + "\n"; +} + +function buildPrerenderTagsBody(tags) { + const lines = tags.map((t) => ` "/challenges/${tagToSlug(t)}",`); + return lines.join("\n") + "\n "; +} + +function buildSeoTagsBody(tags) { + const lines = tags.map((t) => ` "/challenges/${tagToSlug(t)}",`); + return lines.join("\n") + "\n "; +} + +function buildSmokeTagsBody(tags) { + const lines = tags.map( + (t) => ` { path: "/challenges/${tagToSlug(t)}", title: /${escapeRegex(t)} Challenges/ },`, + ); + return lines.join("\n") + "\n "; +} + +function patchRegions(adventures) { + // Sitemap uses the surrounding static URL lines as anchors so the file stays + // free of generator comments. The adventures block sits between the last + // static page (/privacy/) and /challenges/. Both anchor lines must stay + // exactly as-is; do not reorder the static URLs around them. + replaceRegion( + resolve(ROOT, "public/sitemap.xml"), + `<url><loc>https://offon.dev/privacy/</loc><changefreq>yearly</changefreq><priority>0.5</priority></url>`, + `<url><loc>https://offon.dev/challenges/</loc>`, + buildSitemapBody(adventures) + ); + replaceRegion( + resolve(ROOT, "react-router.config.ts"), + "// GENERATED:adventures", + "// /GENERATED:adventures", + buildPrerenderBody(adventures) + ); + replaceRegion( + resolve(ROOT, "src/test/seo.test.ts"), + "// GENERATED:adventures", + "// /GENERATED:adventures", + buildSeoRoutesBody(adventures) + ); + replaceRegion( + resolve(ROOT, "e2e/smoke.spec.ts"), + "// GENERATED:adventures", + "// /GENERATED:adventures", + buildSmokeRoutesBody(adventures) + ); + replaceRegion( + resolve(ROOT, "src/test/prerender.test.ts"), + "// GENERATED:adventures", + "// /GENERATED:adventures", + buildPrerenderTestBody(adventures) + ); + replaceRegion( + resolve(ROOT, "scripts/refresh-leaderboard.mjs"), + "// GENERATED:adventures", + "// /GENERATED:adventures", + buildLeaderboardCategoriesBody(adventures) + ); + + // Challenge tag URLs. Derived from ALL_TAGS across every adventure's `tags:` + // array. Sitemap uses the surrounding `/challenges/` index URL and the closing + // `</urlset>` as anchors so it stays free of generator comments. + const tags = collectAllTags(adventures); + replaceRegion( + resolve(ROOT, "public/sitemap.xml"), + `<url><loc>https://offon.dev/challenges/</loc><changefreq>weekly</changefreq><priority>0.9</priority></url>`, + `</urlset>`, + buildSitemapTagsBody(tags) + ); + replaceRegion( + resolve(ROOT, "react-router.config.ts"), + "// GENERATED:challenge-tags", + "// /GENERATED:challenge-tags", + buildPrerenderTagsBody(tags) + ); + replaceRegion( + resolve(ROOT, "src/test/seo.test.ts"), + "// GENERATED:challenge-tags", + "// /GENERATED:challenge-tags", + buildSeoTagsBody(tags) + ); + replaceRegion( + resolve(ROOT, "e2e/smoke.spec.ts"), + "// GENERATED:challenge-tags", + "// /GENERATED:challenge-tags", + buildSmokeTagsBody(tags) + ); +} + // --- Main --- function main() { @@ -629,6 +853,9 @@ function main() { writeFileSync(summariesPath, summariesContent); console.log(` Generated: src/data/adventures/summaries.ts`); + // Patch GENERATED:adventures regions in route/sitemap/test/leaderboard files. + patchRegions(adventures); + console.log(`\n\x1b[32mDone!\x1b[0m Generated ${adventures.length} adventure file(s) + index.ts + summaries.ts`); } diff --git a/scripts/refresh-leaderboard.mjs b/scripts/refresh-leaderboard.mjs index ee14fcce..3b9e58d9 100644 --- a/scripts/refresh-leaderboard.mjs +++ b/scripts/refresh-leaderboard.mjs @@ -30,13 +30,16 @@ const COMMUNITY_BASE = "https://community.offon.dev"; const QUERY_ID = 5; // Maps adventure ID -> Discourse category ID and which difficulty levels are active. -// Update this when a new adventure is added. +// Generated from src/data/adventures/<id>/adventure.yaml by scripts/generate-adventures.mjs. +// Do not edit the GENERATED block by hand — change adventure.yaml instead. // Category IDs are from: GET https://community.offon.dev/categories.json const ADVENTURE_CATEGORIES = { - "echoes-lost-in-orbit": { categoryId: 35, has_beginner: true, has_intermediate: true, has_expert: true, has_single: false }, - "building-cloudhaven": { categoryId: 36, has_beginner: true, has_intermediate: true, has_expert: true, has_single: false }, - "the-ai-observatory": { categoryId: 37, has_beginner: true, has_intermediate: true, has_expert: true, has_single: false }, - "blind-by-design": { categoryId: 41, has_beginner: true, has_intermediate: true, has_expert: true, has_single: false }, + // GENERATED:adventures + "blind-by-design": { categoryId: 41, has_beginner: true, has_intermediate: true, has_expert: true, has_single: false }, + "building-cloudhaven": { categoryId: 36, has_beginner: true, has_intermediate: true, has_expert: true, has_single: false }, + "echoes-lost-in-orbit": { categoryId: 35, has_beginner: true, has_intermediate: true, has_expert: true, has_single: false }, + "the-ai-observatory": { categoryId: 37, has_beginner: true, has_intermediate: true, has_expert: true, has_single: false }, + // /GENERATED:adventures }; // Load .env file for local development. Never used in CI (secrets are env vars). @@ -62,8 +65,8 @@ function loadDotEnv() { function resolveAvatarUrl(url) { if (!url) return undefined; - if (url.startsWith("http")) return url; - return `${COMMUNITY_BASE}${url}`; + const absolute = url.startsWith("http") ? url : `${COMMUNITY_BASE}${url}`; + return absolute.replace("/user_avatar/community.open-ecosystem.com/", "/user_avatar/community.offon.dev/"); } async function fetchLeaderboard(adventureId, { categoryId, has_beginner, has_intermediate, has_expert, has_single }, apiKey, apiUsername) { diff --git a/scripts/sync-adventure.mjs b/scripts/sync-adventure.mjs index caaa7ac6..d29a827f 100644 --- a/scripts/sync-adventure.mjs +++ b/scripts/sync-adventure.mjs @@ -62,7 +62,8 @@ async function ghApi(endpoint) { try { const { stdout } = await execAsync(`gh api "${endpoint}"`, { encoding: "utf8" }); return JSON.parse(stdout); - } catch { + } catch (err) { + console.warn(` gh api ${endpoint} failed: ${err.stderr?.trim() || err.message}`); return null; } } @@ -84,10 +85,13 @@ function deriveTopics(adventureTags) { } function buildLevel(raw, adventureTags) { + // architecture_diagram is dropped on sync. Diagram SVGs live in src/assets/diagrams/ + // and must be added manually in the PR if a level needs one. + const { architecture_diagram: _ignored, ...rest } = raw; return { - ...raw, - topics: raw.topics || deriveTopics(adventureTags), - verification: raw.verification || VERIFICATION_STUB, + ...rest, + topics: rest.topics || deriveTopics(adventureTags), + verification: rest.verification || VERIFICATION_STUB, }; } @@ -193,6 +197,8 @@ async function main() { ...(indexData.rewards && { rewards: indexData.rewards }), // Preserve contributor set by a reviewer; omit otherwise (PR checklist item) ...(existing?.contributor && { contributor: existing.contributor }), + // Preserve community_category_id once a reviewer has set it; otherwise generator emits a TODO stub. + ...(existing?.community_category_id !== undefined && { community_category_id: existing.community_category_id }), ...(upcomingLevels.length > 0 && { upcoming_levels: upcomingLevels }), levels: mergeLevels(existing?.levels, activeLevels), }; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 1050238b..3feb29fd 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -2,7 +2,7 @@ import type { JSX } from "react"; import { Link } from "react-router"; import { Zap, ExternalLink } from "lucide-react"; import { useTheme } from "@/hooks/useTheme"; -import { BRAND_NAME, BRAND_SLOGAN_PARTS, COMMUNITY_URL, CONTACT_EMAIL, CURRENT_YEAR, LINKEDIN_URL, BLUESKY_URL, X_URL } from "@/data/constants"; +import { BRAND_NAME, BRAND_SHORT_DESCRIPTION, BRAND_SLOGAN_PARTS, COMMUNITY_URL, CONTACT_EMAIL, CURRENT_YEAR, LINKEDIN_URL, BLUESKY_URL, X_URL } from "@/data/constants"; import logoDark from "@/assets/offon-logo-dark-color.svg"; import logoLight from "@/assets/offon-logo-light-color.svg"; @@ -21,7 +21,7 @@ export const Footer = (): JSX.Element => { <img src={theme === "dark" ? logoDark : logoLight} alt="offon.dev" width={104} height={26} loading="lazy" className="h-5" /> </div> <p className="font-sans text-sm text-[hsl(var(--text-secondary))] leading-relaxed md:max-w-xs"> - A vendor-neutral space for open source practitioners to learn through challenges, share what they know, and grow together. + {BRAND_SHORT_DESCRIPTION} </p> </div> diff --git a/src/data/adventures/blind-by-design/adventure.yaml b/src/data/adventures/blind-by-design/adventure.yaml index 262dc6d1..1cb9c930 100644 --- a/src/data/adventures/blind-by-design/adventure.yaml +++ b/src/data/adventures/blind-by-design/adventure.yaml @@ -2,6 +2,7 @@ slug: blind-by-design title: "Blind by Design" icon: FlaskConical month: "MAY 2026" +community_category_id: 41 story: >- Three levels of OpenFeature with flagd as the provider, in a Java + Spring Boot service. Wire the SDK against a flagd sidecar (Beginner), layer evaluation context to target by cohort diff --git a/src/data/adventures/blind-by-design/leaderboard.json b/src/data/adventures/blind-by-design/leaderboard.json index 4ea7ede4..dbbb405e 100644 --- a/src/data/adventures/blind-by-design/leaderboard.json +++ b/src/data/adventures/blind-by-design/leaderboard.json @@ -4,7 +4,7 @@ { "rank": 1, "username": "theharithsa", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/theharithsa/90/297.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/theharithsa/90/297.png", "points": 300, "challengesSolved": 3, "beginnerPoints": 100, diff --git a/src/data/adventures/building-cloudhaven/adventure.yaml b/src/data/adventures/building-cloudhaven/adventure.yaml index c7a81ec0..aff014f9 100644 --- a/src/data/adventures/building-cloudhaven/adventure.yaml +++ b/src/data/adventures/building-cloudhaven/adventure.yaml @@ -2,6 +2,7 @@ slug: building-cloudhaven title: Building CloudHaven icon: Cloud month: JAN 2026 +community_category_id: 36 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. diff --git a/src/data/adventures/building-cloudhaven/leaderboard.json b/src/data/adventures/building-cloudhaven/leaderboard.json index 4ad3037e..4fc7d209 100644 --- a/src/data/adventures/building-cloudhaven/leaderboard.json +++ b/src/data/adventures/building-cloudhaven/leaderboard.json @@ -4,7 +4,7 @@ { "rank": 1, "username": "gsigmund", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/gsigmund/90/99.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/gsigmund/90/99.png", "points": 295, "challengesSolved": 3, "beginnerPoints": 100, @@ -16,7 +16,7 @@ { "rank": 2, "username": "enri-kapaj", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/enri-kapaj/90/165.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/enri-kapaj/90/165.png", "points": 280, "challengesSolved": 3, "beginnerPoints": 90, @@ -28,7 +28,7 @@ { "rank": 3, "username": "justinrand", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/justinrand/90/140.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/justinrand/90/140.png", "points": 240, "challengesSolved": 3, "beginnerPoints": 85, @@ -40,7 +40,7 @@ { "rank": 4, "username": "alvin", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/alvin/90/1.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/alvin/90/1.png", "points": 95, "challengesSolved": 1, "beginnerPoints": 95, diff --git a/src/data/adventures/echoes-lost-in-orbit/adventure.yaml b/src/data/adventures/echoes-lost-in-orbit/adventure.yaml index 28d315c2..623e144b 100644 --- a/src/data/adventures/echoes-lost-in-orbit/adventure.yaml +++ b/src/data/adventures/echoes-lost-in-orbit/adventure.yaml @@ -2,6 +2,7 @@ slug: echoes-lost-in-orbit title: Echoes Lost in Orbit icon: Satellite month: DEC 2025 +community_category_id: 35 story: Restore interstellar communications by fixing broken GitOps setups, progressive delivery systems, and observability pipelines across three galactic missions. tags: diff --git a/src/data/adventures/echoes-lost-in-orbit/leaderboard.json b/src/data/adventures/echoes-lost-in-orbit/leaderboard.json index c27be3b8..aa92bd80 100644 --- a/src/data/adventures/echoes-lost-in-orbit/leaderboard.json +++ b/src/data/adventures/echoes-lost-in-orbit/leaderboard.json @@ -4,7 +4,7 @@ { "rank": 1, "username": "enri-kapaj", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/enri-kapaj/90/165.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/enri-kapaj/90/165.png", "points": 255, "challengesSolved": 3, "beginnerPoints": 60, @@ -16,7 +16,7 @@ { "rank": 2, "username": "gsigmund", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/gsigmund/90/99.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/gsigmund/90/99.png", "points": 220, "challengesSolved": 3, "beginnerPoints": 60, @@ -28,7 +28,7 @@ { "rank": 3, "username": "georgblumenschein", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/georgblumenschein/90/116.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/georgblumenschein/90/116.png", "points": 195, "challengesSolved": 3, "beginnerPoints": 75, @@ -40,7 +40,7 @@ { "rank": 4, "username": "alvin", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/alvin/90/1.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/alvin/90/1.png", "points": 160, "challengesSolved": 2, "beginnerPoints": 70, @@ -52,7 +52,7 @@ { "rank": 5, "username": "justinrand", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/justinrand/90/140.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/justinrand/90/140.png", "points": 155, "challengesSolved": 2, "beginnerPoints": 60, @@ -64,7 +64,7 @@ { "rank": 6, "username": "KatharinaSick", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/KatharinaSick/90/9.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/KatharinaSick/90/9.png", "points": 100, "challengesSolved": 1, "beginnerPoints": 100, @@ -76,7 +76,7 @@ { "rank": 7, "username": "JoshHendrick", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/JoshHendrick/90/45.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/JoshHendrick/90/45.png", "points": 95, "challengesSolved": 1, "beginnerPoints": 95, @@ -88,7 +88,7 @@ { "rank": 8, "username": "rohit_bc", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/rohit_bc/90/164.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/rohit_bc/90/164.png", "points": 90, "challengesSolved": 1, "beginnerPoints": 90, @@ -100,7 +100,7 @@ { "rank": 9, "username": "daniel.y.rigney", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/daniel.y.rigney/90/106.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/daniel.y.rigney/90/106.png", "points": 85, "challengesSolved": 1, "beginnerPoints": 85, @@ -112,7 +112,7 @@ { "rank": 10, "username": "Jelleby", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/Jelleby/90/113.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/Jelleby/90/113.png", "points": 80, "challengesSolved": 1, "beginnerPoints": 80, @@ -124,7 +124,7 @@ { "rank": 11, "username": "RoelofW", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/RoelofW/90/1.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/RoelofW/90/1.png", "points": 70, "challengesSolved": 1, "beginnerPoints": 70, @@ -136,7 +136,7 @@ { "rank": 12, "username": "jayroam", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/jayroam/90/130.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/jayroam/90/130.png", "points": 70, "challengesSolved": 1, "beginnerPoints": 70, @@ -148,7 +148,7 @@ { "rank": 13, "username": "theharithsa", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/theharithsa/90/297.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/theharithsa/90/297.png", "points": 70, "challengesSolved": 1, "beginnerPoints": 70, @@ -160,7 +160,7 @@ { "rank": 14, "username": "yerakh-dyn", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/yerakh-dyn/90/157.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/yerakh-dyn/90/157.png", "points": 60, "challengesSolved": 1, "beginnerPoints": 60, @@ -172,7 +172,7 @@ { "rank": 15, "username": "mikeadityas", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/mikeadityas/90/144.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/mikeadityas/90/144.png", "points": 60, "challengesSolved": 1, "beginnerPoints": 60, diff --git a/src/data/adventures/the-ai-observatory/adventure.yaml b/src/data/adventures/the-ai-observatory/adventure.yaml index cb2b3276..b02a0e78 100644 --- a/src/data/adventures/the-ai-observatory/adventure.yaml +++ b/src/data/adventures/the-ai-observatory/adventure.yaml @@ -2,6 +2,7 @@ slug: the-ai-observatory title: The AI Observatory icon: Telescope month: FEB 2026 +community_category_id: 37 story: Investigate a mysterious bandwidth anomaly at a remote research station by instrumenting its AI system with OpenTelemetry, OpenLLMetry, and Jaeger. tags: diff --git a/src/data/adventures/the-ai-observatory/leaderboard.json b/src/data/adventures/the-ai-observatory/leaderboard.json index 6b724ab5..ffcb699b 100644 --- a/src/data/adventures/the-ai-observatory/leaderboard.json +++ b/src/data/adventures/the-ai-observatory/leaderboard.json @@ -4,7 +4,7 @@ { "rank": 1, "username": "gsigmund", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/gsigmund/90/99.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/gsigmund/90/99.png", "points": 200, "challengesSolved": 2, "beginnerPoints": 100, @@ -16,7 +16,7 @@ { "rank": 2, "username": "rohit_bc", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/rohit_bc/90/164.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/rohit_bc/90/164.png", "points": 120, "challengesSolved": 2, "beginnerPoints": 60, @@ -28,7 +28,7 @@ { "rank": 3, "username": "alvin", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/alvin/90/1.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/alvin/90/1.png", "points": 95, "challengesSolved": 1, "beginnerPoints": 95, @@ -40,7 +40,7 @@ { "rank": 4, "username": "justinrand", - "avatarUrl": "https://community.offon.dev/user_avatar/community.open-ecosystem.com/justinrand/90/140.png", + "avatarUrl": "https://community.offon.dev/user_avatar/community.offon.dev/justinrand/90/140.png", "points": 90, "challengesSolved": 1, "beginnerPoints": 90, diff --git a/src/data/constants.ts b/src/data/constants.ts index 6571e75b..4c83d229 100644 --- a/src/data/constants.ts +++ b/src/data/constants.ts @@ -38,4 +38,4 @@ export const BRAND_SECONDARY_LINE_PARTS = [ ] as const; export const BRAND_SECONDARY_LINE = `${BRAND_SECONDARY_LINE_PARTS[0]} ${BRAND_SECONDARY_LINE_PARTS[1]} ${BRAND_SECONDARY_LINE_PARTS[2]}`; -export const BRAND_SHORT_DESCRIPTION = "A vendor-neutral community for open source enthusiasts. Learn through hands-on challenges, share what you know, and connect with people who love open source."; +export const BRAND_SHORT_DESCRIPTION = "A welcoming open source community to learn through hands-on challenges, share knowledge, and build together."; diff --git a/src/test/prerender.test.ts b/src/test/prerender.test.ts index 319787d0..7efe75fc 100644 --- a/src/test/prerender.test.ts +++ b/src/test/prerender.test.ts @@ -45,32 +45,27 @@ const pages: PageSpec[] = [ file: "handbook/index.html", check: { type: "exact", value: "Handbook - OffOn" }, }, + // GENERATED:adventures { - file: "adventures/echoes-lost-in-orbit/index.html", - check: { type: "contains", value: "Echoes Lost in Orbit" }, + file: "adventures/blind-by-design/index.html", + check: { type: "contains", value: "Blind by Design" }, }, { - file: "adventures/building-cloudhaven/index.html", - check: { type: "contains", value: "Building CloudHaven" }, - }, - { - file: "adventures/the-ai-observatory/index.html", - check: { type: "contains", value: "The AI Observatory" }, + file: "adventures/blind-by-design/levels/beginner/index.html", + check: { type: "contains", value: "Stand up the Lab" }, }, - // Adventure levels: echoes-lost-in-orbit { - file: "adventures/echoes-lost-in-orbit/levels/beginner/index.html", - check: { type: "contains", value: "Broken Echoes" }, + file: "adventures/blind-by-design/levels/intermediate/index.html", + check: { type: "contains", value: "Outcome by Cohort" }, }, { - file: "adventures/echoes-lost-in-orbit/levels/intermediate/index.html", - check: { type: "contains", value: "The Silent Canary" }, + file: "adventures/blind-by-design/levels/expert/index.html", + check: { type: "contains", value: "Read the Chart" }, }, { - file: "adventures/echoes-lost-in-orbit/levels/expert/index.html", - check: { type: "contains", value: "Hyperspace Operations" }, + file: "adventures/building-cloudhaven/index.html", + check: { type: "contains", value: "Building CloudHaven" }, }, - // Adventure levels: building-cloudhaven { file: "adventures/building-cloudhaven/levels/beginner/index.html", check: { type: "contains", value: "The Foundation Stones" }, @@ -83,32 +78,39 @@ const pages: PageSpec[] = [ file: "adventures/building-cloudhaven/levels/expert/index.html", check: { type: "contains", value: "The Guardian Protocols" }, }, - // Adventure levels: the-ai-observatory { - file: "adventures/the-ai-observatory/levels/beginner/index.html", - check: { type: "contains", value: "Calibrating the Lens" }, + file: "adventures/echoes-lost-in-orbit/index.html", + check: { type: "contains", value: "Echoes Lost in Orbit" }, }, { - file: "adventures/the-ai-observatory/levels/intermediate/index.html", - check: { type: "contains", value: "The Distracted Pilot" }, + file: "adventures/echoes-lost-in-orbit/levels/beginner/index.html", + check: { type: "contains", value: "Broken Echoes" }, }, { - file: "adventures/the-ai-observatory/levels/expert/index.html", - check: { type: "contains", value: "The Noise Filter" }, + file: "adventures/echoes-lost-in-orbit/levels/intermediate/index.html", + check: { type: "contains", value: "The Silent Canary" }, }, - // Adventure levels: blind-by-design { - file: "adventures/blind-by-design/levels/beginner/index.html", - check: { type: "contains", value: "Stand up the Lab" }, + file: "adventures/echoes-lost-in-orbit/levels/expert/index.html", + check: { type: "contains", value: "Hyperspace Operations & Transport" }, }, { - file: "adventures/blind-by-design/levels/intermediate/index.html", - check: { type: "contains", value: "Outcome by Cohort" }, + file: "adventures/the-ai-observatory/index.html", + check: { type: "contains", value: "The AI Observatory" }, }, { - file: "adventures/blind-by-design/levels/expert/index.html", - check: { type: "contains", value: "Read the Chart" }, + file: "adventures/the-ai-observatory/levels/beginner/index.html", + check: { type: "contains", value: "Calibrating the Lens" }, + }, + { + file: "adventures/the-ai-observatory/levels/intermediate/index.html", + check: { type: "contains", value: "The Distracted Pilot" }, + }, + { + file: "adventures/the-ai-observatory/levels/expert/index.html", + check: { type: "contains", value: "The Noise Filter" }, }, + // /GENERATED:adventures { file: "challenges/index.html", check: { type: "exact", value: "Open Source Challenges | OffOn" }, diff --git a/src/test/seo.test.ts b/src/test/seo.test.ts index 27b37c0a..9b11fe4b 100644 --- a/src/test/seo.test.ts +++ b/src/test/seo.test.ts @@ -16,25 +16,29 @@ const ROUTES = [ "/handbook", "/privacy", "/accessibility", - "/adventures/echoes-lost-in-orbit", + // GENERATED:adventures + "/adventures/blind-by-design", + "/adventures/blind-by-design/levels/beginner", + "/adventures/blind-by-design/levels/intermediate", + "/adventures/blind-by-design/levels/expert", "/adventures/building-cloudhaven", - "/adventures/the-ai-observatory", - "/adventures/echoes-lost-in-orbit/levels/beginner", - "/adventures/echoes-lost-in-orbit/levels/intermediate", - "/adventures/echoes-lost-in-orbit/levels/expert", "/adventures/building-cloudhaven/levels/beginner", "/adventures/building-cloudhaven/levels/intermediate", "/adventures/building-cloudhaven/levels/expert", + "/adventures/echoes-lost-in-orbit", + "/adventures/echoes-lost-in-orbit/levels/beginner", + "/adventures/echoes-lost-in-orbit/levels/intermediate", + "/adventures/echoes-lost-in-orbit/levels/expert", + "/adventures/the-ai-observatory", "/adventures/the-ai-observatory/levels/beginner", "/adventures/the-ai-observatory/levels/intermediate", "/adventures/the-ai-observatory/levels/expert", - "/adventures/blind-by-design", - "/adventures/blind-by-design/levels/beginner", - "/adventures/blind-by-design/levels/intermediate", - "/adventures/blind-by-design/levels/expert", + // /GENERATED:adventures "/challenges", + // GENERATED:challenge-tags "/challenges/argo-cd", "/challenges/argo-rollouts", + "/challenges/flagd", "/challenges/github-actions", "/challenges/grafana", "/challenges/jaeger", @@ -43,14 +47,14 @@ const ROUTES = [ "/challenges/openllmetry", "/challenges/opentelemetry", "/challenges/opentofu", - "/challenges/promql", "/challenges/prometheus", + "/challenges/promql", "/challenges/python", "/challenges/spring-boot", "/challenges/tdd", "/challenges/terraform", "/challenges/trivy", - "/challenges/flagd", + // /GENERATED:challenge-tags ]; function routeToFile(route: string): string {