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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 39 additions & 31 deletions .github/workflows/new-adventure.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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"
71 changes: 37 additions & 34 deletions .github/workflows/new-level.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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"
8 changes: 4 additions & 4 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/refresh-community-data.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/validate-adventures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
24 changes: 22 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/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/<id>/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/<adventure-id>/<level-id>-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`.
Expand Down Expand Up @@ -646,6 +647,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
Expand Down Expand Up @@ -714,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.

---

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
74 changes: 72 additions & 2 deletions scripts/generate-adventures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];

Expand All @@ -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) {
Expand Down Expand Up @@ -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();
4 changes: 2 additions & 2 deletions src/components/AdventureCard.tsx
Original file line number Diff line number Diff line change
@@ -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 => (
<Link
Expand Down
Loading
Loading