From 1ff58f2307944e399e1e6c8a123b36f85a267527 Mon Sep 17 00:00:00 2001 From: Sinduri Guntupalli Date: Wed, 27 May 2026 14:16:43 +0200 Subject: [PATCH 1/4] fix: align all workflow actions with org allowlist - Pin actions/checkout and actions/setup-node to @v4 across all six workflow files (deploy, preview, new-adventure, new-level, refresh-community-data, validate-adventures) - Replace peter-evans/create-pull-request@v6 in new-adventure and new-level with native git + gh CLI steps - Update README.md to remove the peter-evans reference - Add a GitHub Actions allowlist section to CLAUDE.md so future AI reviews catch violations before they reach CI Signed-off-by: Sinduri Guntupalli --- .github/workflows/deploy.yml | 4 +- .github/workflows/new-adventure.yml | 70 ++++++++++--------- .github/workflows/new-level.yml | 71 ++++++++++---------- .github/workflows/preview.yml | 8 +-- .github/workflows/refresh-community-data.yml | 4 +- .github/workflows/validate-adventures.yml | 4 +- CLAUDE.md | 19 ++++++ README.md | 2 +- 8 files changed, 106 insertions(+), 76 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c98b3502..28c36469 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -19,9 +19,9 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v4 with: node-version-file: .nvmrc cache: npm diff --git a/.github/workflows/new-adventure.yml b/.github/workflows/new-adventure.yml index 722b1c43..283b05be 100644 --- a/.github/workflows/new-adventure.yml +++ b/.github/workflows/new-adventure.yml @@ -31,9 +31,9 @@ jobs: contents: write pull-requests: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -65,32 +65,40 @@ jobs: --levels "$ADVENTURE_LEVELS" - name: Create Pull Request - uses: peter-evans/create-pull-request@v6 - with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: "feat: scaffold adventure ${{ inputs.id }}" - title: "feat: scaffold adventure — ${{ inputs.title }}" - body: | - ## New Adventure Scaffolded - - | Field | Value | - |---|---| - | ID | `${{ inputs.id }}` | - | Title | ${{ inputs.title }} | - | Month | ${{ inputs.month }} | - | Levels | ${{ inputs.levels }} | - - ### Generated files - - `src/data/adventures/${{ inputs.id }}/adventure.yaml` (fill in TODOs) - - Discussion JSON stubs per level - - Routes added to `react-router.config.ts` - - Sitemap entries added - - ### Next steps - 1. Fill in the TODOs in `src/data/adventures/${{ inputs.id }}/adventure.yaml` - 2. Update `discussionUrl` in the YAML and each level's `-posts.json` file - 3. Run `npm run generate` to produce the TypeScript from YAML - 4. Run `node scripts/refresh-discussions.mjs` - 5. Run all checks: `npm run lint && npm test && npm run build && npm run test:e2e` - branch: "feat/adventure-${{ inputs.id }}" - delete-branch: true + 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 "$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' + printf -- '- `src/data/adventures/%s/adventure.yaml` (fill in TODOs)\n' "$ADVENTURE_ID" + printf -- '- Discussion JSON stubs per level\n' + printf -- '- Routes added to `react-router.config.ts`\n' + printf -- '- Sitemap entries added\n\n' + printf '### Next steps\n' + printf '1. Fill in the TODOs in `src/data/adventures/%s/adventure.yaml`\n' "$ADVENTURE_ID" + printf '2. Update `discussionUrl` in the YAML and each level `-posts.json` file\n' + printf '3. Run `npm run generate` to produce the TypeScript from YAML\n' + printf '4. Run `node scripts/refresh-discussions.mjs`\n' + printf '5. 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 index b78ea515..b749e6ea 100644 --- a/.github/workflows/new-level.yml +++ b/.github/workflows/new-level.yml @@ -26,9 +26,9 @@ jobs: contents: write pull-requests: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -59,35 +59,38 @@ jobs: echo "EOF" >> "$GITHUB_OUTPUT" - name: Create Pull Request - uses: peter-evans/create-pull-request@v6 - with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: "feat: add ${{ inputs.level }} level to ${{ inputs.adventure }}" - title: "feat: add ${{ inputs.level }} level — ${{ inputs.adventure }}" - body: | - ## New Level Added - - | Field | Value | - |---|---| - | Adventure | `${{ inputs.adventure }}` | - | Level | `${{ inputs.level }}` | - - ### Generated - - `src/data/adventures/${{ inputs.adventure }}/${{ inputs.level }}-posts.json` (discussion stub) - - Prerender entry in `react-router.config.ts` - - Sitemap entry in `public/sitemap.xml` - - ### Manual step required - Paste this into the `levels` array in `src/data/adventures/${{ inputs.adventure }}/adventure.yaml`: - - ```yaml - ${{ steps.scaffold.outputs.snippet }} - ``` - - ### Next steps - 1. Paste the snippet above and fill in the TODOs - 2. Update `discussionUrl` in the YAML and the `-posts.json` file - 3. Run `node scripts/refresh-discussions.mjs` - 4. Run all checks: `npm run lint && npm test && npm run build && npm run test:e2e` - branch: "feat/${{ inputs.adventure }}-${{ inputs.level }}" - delete-branch: true + 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 "$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\n' + printf -- '- `src/data/adventures/%s/%s-posts.json` (discussion stub)\n' "$ADVENTURE" "$LEVEL" + printf -- '- Prerender entry in `react-router.config.ts`\n' + printf -- '- Sitemap entry in `public/sitemap.xml`\n\n' + printf '### Manual step required\n' + printf 'Paste this into the `levels` array in `src/data/adventures/%s/adventure.yaml`:\n\n' "$ADVENTURE" + printf '```yaml\n%s\n```\n\n' "$SNIPPET" + printf '### Next steps\n' + printf '1. Paste the snippet above and fill in the TODOs\n' + printf '2. Update `discussionUrl` in the YAML and the `-posts.json` file\n' + printf '3. Run `node scripts/refresh-discussions.mjs`\n' + printf '4. 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/preview.yml b/.github/workflows/preview.yml index 00e9ce99..be467a8e 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -16,9 +16,9 @@ jobs: if: github.event.action != 'closed' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v4 with: node-version-file: .nvmrc cache: npm @@ -42,9 +42,9 @@ jobs: if: always() && (needs.smoke.result == 'success' || needs.smoke.result == 'skipped') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v4 with: node-version-file: .nvmrc cache: npm diff --git a/.github/workflows/refresh-community-data.yml b/.github/workflows/refresh-community-data.yml index 503cc0df..209040b6 100644 --- a/.github/workflows/refresh-community-data.yml +++ b/.github/workflows/refresh-community-data.yml @@ -21,9 +21,9 @@ jobs: refresh: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v4 with: node-version-file: ".nvmrc" diff --git a/.github/workflows/validate-adventures.yml b/.github/workflows/validate-adventures.yml index 32641832..d836a931 100644 --- a/.github/workflows/validate-adventures.yml +++ b/.github/workflows/validate-adventures.yml @@ -18,9 +18,9 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' cache: 'npm' diff --git a/CLAUDE.md b/CLAUDE.md index 1ec516d4..258c8d7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -646,6 +646,25 @@ Complete checklist for every new adventure: - Only static files in `dist/client/` are deployed. No server config is needed. - The base path is set via the `VITE_BASE_PATH` environment variable (defaults to `/`). PR previews set this automatically in `preview.yml`. Never change this without verifying GitHub Pages routing. +### GitHub Actions allowlist + +The `off-on-dev` organisation restricts which third-party actions can run. Only the following are permitted: + +| Action | Pinned version | +|---|---| +| `actions/checkout` | `@v4` — never `@v5`, `@v6`, or any other version | +| `actions/setup-node` | `@v4` — never `@v5`, `@v6`, or any other version | +| `JamesIves/github-pages-deploy-action` | any tag (`@*`) | +| `marocchino/sticky-pull-request-comment` | any tag (`@*`) | +| `rossjrw/pr-preview-action` | any tag (`@*`) | +| Actions owned by `off-on-dev` | any | +| Actions created by GitHub | any | +| Actions verified in the GitHub Marketplace | any | + +**Before adding any new `uses:` line to a workflow file, verify the action is on this list.** If it is not, replace it with an equivalent using `gh` (GitHub CLI, pre-installed on all `ubuntu-latest` runners) or native shell commands. Do not ask for the allowlist to be expanded unless there is no alternative. + +This rule is enforced at the org level. A workflow that references an action outside the allowlist will fail immediately with an "action is not allowed" error — it will never even reach the step that uses it. + --- ## Before Submitting Code diff --git a/README.md b/README.md index b80a85d9..9f2cfe27 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ Go to the repository's **Actions** tab, select the workflow, click **Run workflo | **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 | -Both workflows create a branch and open a PR automatically via `peter-evans/create-pull-request`. The PR description includes next steps (fill in content TODOs, run verification, etc.). +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.). ### Via local scripts From b8c32a48594b6856aab161cdb881360f6c6f37f8 Mon Sep 17 00:00:00 2001 From: Sinduri Guntupalli Date: Wed, 27 May 2026 14:33:45 +0200 Subject: [PATCH 2/4] refactor: cleanup dead code and extract shared components - Delete LinkSection (exported but never imported anywhere) - Remove export from BRAND_SECONDARY_LINE_WORD (internal only) - Extract TagChips component; replace 3 identical inline chip maps in AdventureDetail, ChallengeDetail (structured + legacy layouts) - Extract CodespacesButton component; replace 2 identical button blocks (mobile CTA and desktop sidebar) in ChallengeDetail - Simplify StructuredLayout to accept (adventure, level) directly; remove 10 pass-through variable declarations in ChallengeDetail Signed-off-by: Sinduri Guntupalli --- src/components/CodespacesButton.tsx | 24 +++++++ src/components/LinkSection.tsx | 27 -------- src/components/TagChips.tsx | 21 ++++++ src/data/constants.ts | 2 +- src/pages/AdventureDetail.tsx | 13 +--- src/pages/ChallengeDetail.tsx | 104 +++------------------------- 6 files changed, 60 insertions(+), 131 deletions(-) create mode 100644 src/components/CodespacesButton.tsx delete mode 100644 src/components/LinkSection.tsx create mode 100644 src/components/TagChips.tsx diff --git a/src/components/CodespacesButton.tsx b/src/components/CodespacesButton.tsx new file mode 100644 index 00000000..f9af4d48 --- /dev/null +++ b/src/components/CodespacesButton.tsx @@ -0,0 +1,24 @@ +import { type JSX } from "react"; +import { ExternalLink } from "lucide-react"; + +type CodespacesButtonProps = { + href: string; + fullWidth?: boolean; +}; + +export const CodespacesButton = ({ href, fullWidth = false }: CodespacesButtonProps): JSX.Element => ( + <> + + Open in Codespaces +

+ Free GitHub account required +

+ +); diff --git a/src/components/LinkSection.tsx b/src/components/LinkSection.tsx deleted file mode 100644 index c73104b4..00000000 --- a/src/components/LinkSection.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { JSX } from "react"; -import { ExternalLink } from "lucide-react"; - -type LinkItem = { label: string; href: string }; - -type LinkSectionProps = { - heading: string; - links: LinkItem[]; -}; - -const extLinkCls = "docs-ext-link"; - -export const LinkSection = ({ heading, links }: LinkSectionProps): JSX.Element => ( -
-

{heading}

- -
-); diff --git a/src/components/TagChips.tsx b/src/components/TagChips.tsx new file mode 100644 index 00000000..c2240102 --- /dev/null +++ b/src/components/TagChips.tsx @@ -0,0 +1,21 @@ +import { type JSX } from "react"; +import { Link } from "react-router"; +import { tagToSlug } from "@/data/adventures"; + +type TagChipsProps = { + tags: readonly string[]; +}; + +export const TagChips = ({ tags }: TagChipsProps): JSX.Element => ( + <> + {tags.map((tag) => ( + + {tag} + + ))} + +); diff --git a/src/data/constants.ts b/src/data/constants.ts index e435193d..1e2fd9da 100644 --- a/src/data/constants.ts +++ b/src/data/constants.ts @@ -30,7 +30,7 @@ export const CONTACT_EMAIL = "offondev@gmail.com"; export const BRAND_SLOGAN_PARTS = ["Vendor-Neutral", "Open Source", "Community-Driven"] as const; export const BRAND_SLOGAN = BRAND_SLOGAN_PARTS.join(". "); -export const BRAND_SECONDARY_LINE_WORD = "always"; +const BRAND_SECONDARY_LINE_WORD = "always"; export const BRAND_SECONDARY_LINE_PARTS = [ `${BRAND_SECONDARY_LINE_WORD} On.`, `${BRAND_SECONDARY_LINE_WORD} Open.`, diff --git a/src/pages/AdventureDetail.tsx b/src/pages/AdventureDetail.tsx index cd18a713..97dba86d 100644 --- a/src/pages/AdventureDetail.tsx +++ b/src/pages/AdventureDetail.tsx @@ -2,7 +2,7 @@ import { type JSX } from "react"; import { useParams, Link } from "react-router"; import type { MetaFunction } from "react-router"; import { ArrowRight } from "lucide-react"; -import { ADVENTURES, type AdventureLevel, tagToSlug } from "@/data/adventures"; +import { ADVENTURES, type AdventureLevel } from "@/data/adventures"; import { NotFoundPage } from "@/components/NotFoundPage"; import { Navbar } from "@/components/Navbar"; import { Footer } from "@/components/Footer"; @@ -13,6 +13,7 @@ import { TechFilterSection } from "@/components/TechFilterSection"; import { RewardsCard } from "@/components/RewardsCard"; import { AdventureLeaderboard } from "@/components/AdventureLeaderboard"; import { ContributorBadge } from "@/components/ContributorBadge"; +import { TagChips } from "@/components/TagChips"; import { SITE_URL, BRAND_NAME } from "@/data/constants"; import { buildPageMeta } from "@/lib/meta"; @@ -97,15 +98,7 @@ const AdventureDetail = (): JSX.Element => { {adventure.contributor && ( )} - {adventure.tags.map((tag) => ( - - {tag} - - ))} +

{adventure.story}

diff --git a/src/pages/ChallengeDetail.tsx b/src/pages/ChallengeDetail.tsx index 29b6bfbb..cc9ffec4 100644 --- a/src/pages/ChallengeDetail.tsx +++ b/src/pages/ChallengeDetail.tsx @@ -2,7 +2,9 @@ import { type JSX } from "react"; import { useParams, Link } from "react-router"; import type { MetaFunction } from "react-router"; import { ArrowLeft, Check, ExternalLink } from "lucide-react"; -import { ADVENTURES, tagToSlug } from "@/data/adventures"; +import { ADVENTURES } from "@/data/adventures"; +import { TagChips } from "@/components/TagChips"; +import { CodespacesButton } from "@/components/CodespacesButton"; import { DifficultyBadge } from "@/components/DifficultyBadge"; import { ContributorBadge } from "@/components/ContributorBadge"; import { NotFoundPage } from "@/components/NotFoundPage"; @@ -56,32 +58,10 @@ export const meta: MetaFunction = ({ params }) => { type StructuredLayoutProps = { adventure: (typeof ADVENTURES)[number]; level: (typeof ADVENTURES)[number]["levels"][number]; - intro: string[] | undefined; - objective: string[] | undefined; - toolbox: { name: string; url?: string; description?: string }[] | undefined; - backstory: string[] | undefined; - architecture: string[] | undefined; - architectureDiagram: string | undefined; - diagramAlt: string | undefined; - howToPlay: { title: string; body: string }[] | undefined; - helpfulLinks: { label: string; url: string }[] | undefined; - verification: (typeof ADVENTURES)[number]["levels"][number]["verification"]; }; -const StructuredLayout = ({ - adventure, - level, - intro, - objective, - toolbox, - backstory, - architecture, - architectureDiagram, - diagramAlt, - howToPlay, - helpfulLinks, - verification, -}: StructuredLayoutProps): JSX.Element => { +const StructuredLayout = ({ adventure, level }: StructuredLayoutProps): JSX.Element => { + const { intro, objective, toolbox, backstory, architecture, architectureDiagram, diagramAlt, howToPlay, helpfulLinks, verification } = level; return ( <> {/* Header */} @@ -93,15 +73,7 @@ const StructuredLayout = ({
- {(level.topics ?? adventure.tags).map((tag) => ( - - {tag} - - ))} +
{/* Intro as hook */} @@ -181,18 +153,7 @@ const StructuredLayout = ({

- - Open in Codespaces -

- Free GitHub account required -

+
@@ -326,18 +287,7 @@ const StructuredLayout = ({ {/* CTA panel - hidden on mobile where inline CTA is shown */}
- - Open in Codespaces -

- Free GitHub account required -

+
{ return ; } - const intro = level.intro; - const backstory = level.backstory; - const architecture = level.architecture; - const architectureDiagram = level.architectureDiagram; - const diagramAlt = level.diagramAlt; - const objective = level.objective; - const toolbox = level.toolbox; - const howToPlay = level.howToPlay; - const helpfulLinks = level.helpfulLinks; - const verification = level.verification; - const hasStructuredContent = !!( - intro || backstory || architecture || toolbox || howToPlay || helpfulLinks + level.intro || level.backstory || level.architecture || level.toolbox || level.howToPlay || level.helpfulLinks ); return ( @@ -401,20 +340,7 @@ const ChallengeDetail = (): JSX.Element => { {hasStructuredContent ? ( - + ) : ( <> {/* Legacy layout for levels without structured content */} @@ -423,15 +349,7 @@ const ChallengeDetail = (): JSX.Element => {

{adventure.title}

- {(level.topics ?? adventure.tags).map((tag) => ( - - {tag} - - ))} +
{adventure.contributor && (
From e4e79c59152dcd09170cc0652b0002d8e5c7b12d Mon Sep 17 00:00:00 2001 From: Sinduri Guntupalli Date: Wed, 27 May 2026 15:34:25 +0200 Subject: [PATCH 3/4] perf: split adventure summaries bundle and add font preloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Generate summaries.ts from YAML (id, title, month, story, tags, contributor name, level id/name/difficulty/topics/learnings only) — no imports from the full *.generated.ts files, so bundlers can keep it in its own chunk separate from walkthrough steps, toolbox items, architecture diagrams, and other detail-page fields - Add AdventureLevelSummary, AdventureCardSummary, and RelatedLevelSummary types to types.ts; re-export them from index.ts - Switch AdventureCard, FilteredLevelCard, and ChallengesGrid to import from summaries.ts; pages and components that need full adventure data continue to import from index.ts - Add font preloads to root.tsx via the links() export (inter-latin-400/500, syne-latin-700); font-display: optional requires preloads to keep fonts visible on throttled connections - Add vendor-react manual chunk to vite.config.ts to isolate React/ReactDOM/scheduler into a long-lived cacheable chunk - Remove btn-primary idle pulse glow (ctaGlow keyframes) and btn-ghost hover glow; remove bg-primary/10 hover fill from btn-ghost; clean up corresponding light mode overrides - Add fetchPriority="low" to the non-LCP light-mode logo in Navbar - Update tests, styleguide.md, and CLAUDE.md to document the summaries.ts pattern Signed-off-by: Sinduri Guntupalli --- CLAUDE.md | 5 +- scripts/generate-adventures.mjs | 74 ++++++++++- src/components/AdventureCard.tsx | 4 +- src/components/ChallengesGrid.tsx | 8 +- src/components/FilteredLevelCard.tsx | 4 +- src/components/Navbar.tsx | 2 +- src/data/adventures/index.ts | 2 +- src/data/adventures/summaries.ts | 188 +++++++++++++++++++++++++++ src/data/adventures/types.ts | 31 +++++ src/index.css | 39 +----- src/root.tsx | 12 ++ src/test/root.test.tsx | 13 +- styleguide.md | 6 +- vite.config.ts | 15 +++ 14 files changed, 343 insertions(+), 60 deletions(-) create mode 100644 src/data/adventures/summaries.ts diff --git a/CLAUDE.md b/CLAUDE.md index 258c8d7d..28316eb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -228,7 +228,8 @@ without exception. They exist to prevent debugging by accumulation. - Static content lives in `src/data/` as typed TypeScript objects/arrays. - No runtime `fetch` calls in components. All network data must be fetched at build time. -- **Adventure content pipeline:** Adventure data is authored as YAML files at `src/data/adventures//adventure.yaml` and compiled to TypeScript via `scripts/generate-adventures.mjs`. The generated files (`*.generated.ts` and `index.ts`) are committed to the repo. The `prebuild` hook runs the generator automatically before every build. Never edit `*.generated.ts` or `src/data/adventures/index.ts` by hand. +- **Adventure content pipeline:** Adventure data is authored as YAML files at `src/data/adventures//adventure.yaml` and compiled to TypeScript via `scripts/generate-adventures.mjs`. The generated files (`*.generated.ts`, `index.ts`, and `summaries.ts`) are committed to the repo. The `prebuild` hook runs the generator automatically before every build. Never edit `*.generated.ts`, `src/data/adventures/index.ts`, or `src/data/adventures/summaries.ts` by hand. + - **`summaries.ts` vs `index.ts`:** `summaries.ts` is a lightweight snapshot (id, title, month, story, tags, contributor name, and per-level id/name/difficulty/topics/learnings) with no imports from the full `*.generated.ts` files. Components that only render cards or tag filters (e.g. `ChallengesGrid`, `AdventureCard`, `FilteredLevelCard`) must import from `@/data/adventures/summaries` to avoid pulling the full detail-page data into the home page bundle. Detail pages and components that need full adventure content import from `@/data/adventures`. - **Why YAML + generated TS instead of writing TS directly?** YAML is easier to author and review for non-engineers, and validated by JSON Schema. Vite cannot import YAML natively, so a generator converts it to fully-typed TS that the app can statically import. Committing the generated files means the build works without running the generator first, and CI can detect when generated output is out of sync with the source YAML. - **Schema validation:** Adventure YAML files are validated against `schemas/adventure.schema.json` (JSON Schema Draft 2020-12). Run `npm run generate:validate` to check without writing files. - **Build-time fetching:** Discussion data lives in per-level JSON files under `src/data/adventures//-posts.json`. Each file contains only `discussionUrl`, `discussionPosts`, and `totalReplies`. These are refreshed hourly by the GitHub Action in `.github/workflows/refresh-community-data.yml` (runs `scripts/refresh-discussions.mjs`). Components import the JSON dynamically via `import.meta.glob`. @@ -733,7 +734,7 @@ State the result of each check explicitly before finishing a task. - Do not change the `@theme` block in `src/index.css` without verifying the change does not break existing components. - Do not reinstall `@radix-ui/*` packages that were removed. If a Radix primitive is genuinely needed, check whether raw HTML with Tailwind solves the problem first. - Do not re-derive data from `ADVENTURES` inside component files. Any computed value that belongs to the data layer (e.g. a deduplicated tag list) should be exported from `src/data/adventures/index.ts` and imported. `ALL_TAGS` is the established pattern. -- Do not edit `*.generated.ts` or `src/data/adventures/index.ts` by hand. These are produced by `scripts/generate-adventures.mjs` from the YAML source files. Edit the YAML and run `npm run generate` instead. +- Do not edit `*.generated.ts`, `src/data/adventures/index.ts`, or `src/data/adventures/summaries.ts` by hand. These are produced by `scripts/generate-adventures.mjs` from the YAML source files. Edit the YAML and run `npm run generate` instead. --- diff --git a/scripts/generate-adventures.mjs b/scripts/generate-adventures.mjs index 890a1096..a9e51a28 100644 --- a/scripts/generate-adventures.mjs +++ b/scripts/generate-adventures.mjs @@ -333,6 +333,70 @@ function generateAdventureTs(data) { return lines.join("\n"); } +/** + * Generate summaries.ts — a lightweight card-only snapshot of all adventure data. + * This file has NO imports from the full *.generated.ts files, so bundlers can + * split it into a separate chunk. Pages that only render AdventureCard and + * FilteredLevelCard import from here instead of from index.ts, saving ~12 KB + * gzipped on the home page by not pulling in walkthrough steps, toolbox items, + * architecture sections, and other detail-page fields. + */ +function generateSummariesTs(adventures) { + const lines = []; + lines.push(`// Generated by scripts/generate-adventures.mjs — do not edit by hand.`); + lines.push(`import type { AdventureCardSummary, RelatedLevelSummary } from "./types";`); + lines.push(``); + lines.push(`export const ADVENTURE_SUMMARIES: AdventureCardSummary[] = [`); + + for (const data of adventures) { + lines.push(` {`); + lines.push(` id: "${data.id}",`); + lines.push(` title: "${escapeDoubleQuoted(data.title)}",`); + lines.push(` month: "${data.month}",`); + lines.push(` story: ${formatString(data.story)},`); + 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}",`); + 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(` },`); + } + lines.push(` ],`); + lines.push(` },`); + } + + lines.push(`];`); + lines.push(``); + lines.push(`/** All unique technology tags across all adventures, for card and filter views. */`); + lines.push(`export const SUMMARY_TAGS: string[] = Array.from(`); + lines.push(` new Set(ADVENTURE_SUMMARIES.flatMap((a) => a.tags))`); + lines.push(`).sort();`); + lines.push(``); + lines.push(`/** Returns level summaries matching a tag, for filtered card views on the home page. */`); + lines.push(`export const getLevelSummariesByTag = (tag: string): RelatedLevelSummary[] =>`); + lines.push(` ADVENTURE_SUMMARIES`); + lines.push(` .filter((a) => a.tags.includes(tag))`); + lines.push(` .flatMap((a) =>`); + lines.push(` a.levels.map((level) => ({`); + lines.push(` level,`); + lines.push(` adventureId: a.id,`); + lines.push(` adventureTitle: a.title,`); + lines.push(` }))`); + lines.push(` );`); + lines.push(``); + + return lines.join("\n"); +} + function generateIndexTs(adventures) { const lines = []; @@ -343,7 +407,7 @@ function generateIndexTs(adventures) { } lines.push(`import type { Adventure, AdventureContributor, RelatedLevel } from "./types";`); lines.push(``); - lines.push(`export type { Adventure, AdventureLevel, AdventureContributor, RelatedLevel, ToolboxItem, WalkthroughStep, VerificationInfo, TopPlayer, UpcomingLevel } from "./types";`); + lines.push(`export type { Adventure, AdventureLevel, AdventureContributor, RelatedLevel, ToolboxItem, WalkthroughStep, VerificationInfo, TopPlayer, UpcomingLevel, AdventureLevelSummary, AdventureCardSummary, RelatedLevelSummary } from "./types";`); lines.push(``); lines.push(`export const ADVENTURES: Adventure[] = [`); for (const adv of adventures) { @@ -460,7 +524,13 @@ function main() { writeFileSync(indexPath, indexContent); console.log(` Generated: src/data/adventures/index.ts`); - console.log(`\n\x1b[32mDone!\x1b[0m Generated ${adventures.length} adventure file(s) + index.ts`); + // Generate summaries.ts (lightweight card-only data, no imports from full generated files) + const summariesContent = generateSummariesTs(adventures); + const summariesPath = resolve(ADVENTURES_DIR, "summaries.ts"); + writeFileSync(summariesPath, summariesContent); + console.log(` Generated: src/data/adventures/summaries.ts`); + + console.log(`\n\x1b[32mDone!\x1b[0m Generated ${adventures.length} adventure file(s) + index.ts + summaries.ts`); } main(); diff --git a/src/components/AdventureCard.tsx b/src/components/AdventureCard.tsx index 7ef2eb19..2ed78173 100644 --- a/src/components/AdventureCard.tsx +++ b/src/components/AdventureCard.tsx @@ -1,11 +1,11 @@ import type { JSX } from "react"; import { Link } from "react-router"; import { Layers } from "lucide-react"; -import type { Adventure } from "@/data/adventures"; +import type { AdventureCardSummary } from "@/data/adventures"; import { DifficultyBadge } from "@/components/DifficultyBadge"; import { ContributorBadge } from "@/components/ContributorBadge"; -type AdventureCardProps = { adventure: Adventure }; +type AdventureCardProps = { adventure: AdventureCardSummary }; export const AdventureCard = ({ adventure }: AdventureCardProps): JSX.Element => ( { const [activeTopic, setActiveTopic] = useState(null); - const filteredLevels = activeTopic ? getLevelsByTag(activeTopic) : []; + const filteredLevels = activeTopic ? getLevelSummariesByTag(activeTopic) : []; return (
@@ -20,7 +20,7 @@ export const ChallengesGrid = (): JSX.Element => { {/* Topic filter chips */}
- {ALL_TAGS.map((tag) => ( + {SUMMARY_TAGS.map((tag) => (