diff --git a/.ai/templates/generate-reveal-zip.mjs b/.ai/templates/generate-reveal-zip.mjs index b11f4c9c4..775de1b6c 100644 --- a/.ai/templates/generate-reveal-zip.mjs +++ b/.ai/templates/generate-reveal-zip.mjs @@ -4,9 +4,14 @@ * * Run: node .ai/templates/generate-reveal-zip.mjs * + * Requires jszip (devDependency). The reveal assets are read from the committed + * public/reveal/, not from node_modules, so reveal.js itself is not needed to run this. + * * Re-vendor note: public/reveal/ is a hand-vendored four-file subset of reveal.js@6.0.1. + * reveal.js is deliberately NOT a dependency; install it ad hoc when updating + * (npm install reveal.js@6, same pattern as pptxgenjs for the PPTX generator). * The full dist/ tree is NOT copied. When updating Reveal, copy only these four files from - * node_modules/reveal.js/dist/ to public/reveal/ — do not copy the whole directory, as + * node_modules/reveal.js/dist/ to public/reveal/ - do not copy the whole directory, as * that would restore deleted themes and plugins along with their OFL and BSD attribution * obligations. The four required files are: reset.css, reveal.css, reveal.js, * plugin/notes.js. diff --git a/.claude/commands/add-solution.md b/.claude/commands/add-solution.md index 0e878d766..bae83a6f2 100644 --- a/.claude/commands/add-solution.md +++ b/.claude/commands/add-solution.md @@ -188,13 +188,13 @@ If lint or build fail, fix the issues before reporting done. Check the built HTML contains the solution title: ```bash -grep -c "" dist/client/adventures//levels//solution/index.html +grep -c "" dist/adventures//levels//solution/index.html ``` Use a unique word from the solution title as the fragment. If a contributor was provided, also confirm their name appears: ```bash -grep -c "" dist/client/adventures//levels//solution/index.html +grep -c "" dist/adventures//levels//solution/index.html ``` Report success with the path if both pass. diff --git a/.claude/commands/create-presentation.md b/.claude/commands/create-presentation.md index f086ca48d..2bf551cf3 100644 --- a/.claude/commands/create-presentation.md +++ b/.claude/commands/create-presentation.md @@ -16,7 +16,7 @@ Generate a presentation that matches the OffOn design system, in the format of y 2. Reads the appropriate template file. 3. Generates slide content and fills in the placeholders. 4. Writes the output file to `public/`. -5. Does not touch `public/sitemap.xml`, `react-router.config.ts`, or `src/routes.ts`. +5. Does not touch `src/pages/sitemap.xml.ts`, `astro.config.mjs` redirects, or test route lists — presentations go in `public/` and are not Astro pages. --- @@ -517,7 +517,7 @@ When creating a deck for a challenge walkthrough: - Slides 3–N: One slide per major step. Use `.sh` header with step number as overline label. - Final slide: What the learner accomplished + link to the challenge on offon.dev. -For scenario and architecture text, pull from the adventure's generated TypeScript in `src/data/adventures//.generated.ts`. The `scenario`, `architecture`, `backstory`, and `objective` fields are pre-rendered HTML; strip tags or paraphrase for slide copy; do not paste raw HTML into the deck. +For scenario and architecture text, pull from the adventure YAML at `src/data/adventures//adventure.yaml`. The `scenario`, `architecture`, `backstory`, and `objective` fields contain markdown; paraphrase for slide copy. Do not use or look for `*.generated.ts` files — they no longer exist. --- @@ -529,7 +529,7 @@ For scenario and architecture text, pull from the adventure's generated TypeScri 4. Update the title in `` (Reveal.js). 5. Write to: - Reveal.js: `public/<event-slug>/index.html` (create the subfolder if it does not exist) -6. Do not add any of these files to `src/routes.ts`, `public/sitemap.xml`, or `react-router.config.ts`. +6. Do not add any of these files to `src/pages/sitemap.xml.ts` or the test route lists in `e2e/` — presentations live under `public/` and are not Astro pages. 7. Confirm: `ls -lh <output-path>` **For PowerPoint (`pptx`):** edit `.ai/templates/generate-pptx.mjs` with the presentation content, then run: diff --git a/.claude/commands/navigation.md b/.claude/commands/navigation.md index b870caa16..01a47d579 100644 --- a/.claude/commands/navigation.md +++ b/.claude/commands/navigation.md @@ -103,12 +103,12 @@ Dragon NaturallySpeaking and iOS Voice Control navigate by speaking visible link If `aria-label` differs from visible text, the user cannot activate the link by speaking what they see. **The accessible name must contain the visible text.** -## SPA/React Router Note +## SPA Navigation Note -After route changes, screen reader users hear nothing unless focus is managed. When React -Router navigates, move focus to the new page's `<h1>` or the skip link target, and ensure -the page title updates. This is already handled by React Router v7's framework mode — -verify it is not broken when adding new routes. +After route changes, screen reader users hear nothing unless focus is managed. Astro's +`<ClientRouter />` emits a route announcer and moves focus to `#main-content` after each +client-side navigation — this is wired in `Layout.astro`. Verify it is not broken when +adding new routes. ## Definition of Done Checklist diff --git a/.claude/commands/progressive-enhancement.md b/.claude/commands/progressive-enhancement.md index f816ca8f6..a0b4687e2 100644 --- a/.claude/commands/progressive-enhancement.md +++ b/.claude/commands/progressive-enhancement.md @@ -64,10 +64,10 @@ if ('fetch' in window && 'querySelector' in document) { ## Critical: Core Content Must Not Require JavaScript -This site uses React Router v7 with `ssr: false` (static prerendering). Pages are -prerendered at build time so content is in the HTML. Verify that: +This site uses Astro with `output: 'static'`. Pages are prerendered at build time +so content is in the HTML. Verify that: -- Every page's core content is in the prerendered HTML output in `dist/client/` +- Every page's core content is in the prerendered HTML output in `dist/` - No critical information is rendered exclusively client-side after hydration - Filter/search UI degrades gracefully (content visible even when JS filtering is unavailable) diff --git a/.claude/commands/user-personalization.md b/.claude/commands/user-personalization.md index c21d2233d..b7255fd43 100644 --- a/.claude/commands/user-personalization.md +++ b/.claude/commands/user-personalization.md @@ -49,7 +49,7 @@ in `src/index.css`. Check every new animation or color change against these quer } } -@media (prefers-color-scheme: dark) { /* handled by useTheme hook */ } +@media (prefers-color-scheme: dark) { /* handled by the inline pre-paint script in Layout.astro */ } @media (prefers-contrast: more) { :root { @@ -70,14 +70,14 @@ in `src/index.css`. Check every new animation or color change against these quer This site manages two user preferences: -**Theme (light/dark):** Handled by `useTheme` hook in `src/hooks/useTheme.tsx`. -- Stored in `localStorage` under the `theme` key (see `THEME_STORAGE_KEY` in `src/data/constants.ts`) -- Initialized to `dark` on first render, updated in `useEffect` from stored value -- Never read `localStorage` during render — hydration safety rule applies +**Theme (light/dark):** Handled by `ThemeToggle.astro` and an inline pre-paint script in `Layout.astro`. +- Stored in `localStorage` under the `theme` key +- The pre-paint script reads it before first paint to avoid FOUC; `ThemeToggle.astro` toggles it via a delegated `click` listener on `document` +- Never read `localStorage` during server-side render — only in the inline pre-paint script or in an `astro:page-load` handler -**Analytics consent:** Handled by `useConsent` hook in `src/hooks/useConsent.tsx`. +**Analytics consent:** State lives in the `$consent` nanostore (`src/stores/consent.ts`). - Stored in `localStorage` under `analytics_consent` key -- The consent banner is the personalization UI for this preference +- `ConsentBanner.astro` is the personalization UI; its script re-subscribes to `$consent` on every `astro:page-load` - All `localStorage` access must be in `try/catch` blocks ## Moderate: Safe localStorage Pattern diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ae55ea7ad..ddff3c064 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,7 +10,7 @@ ## Manual checks - [ ] Screen reader tested _(UI changes only)_ -- [ ] New routes added to sitemap.xml, prerender array, README _(routes only)_ +- [ ] New routes added to e2e/a11y.spec.ts, e2e/smoke.spec.ts, sitemap.xml.ts, and README _(routes only; sitemap is auto-generated from getStaticPaths, so only static pages need manual entries)_ - [ ] UI verified at 375px, 768px, and 1280px against the production build (`npm run build && npm run preview`) _(UI changes only)_ - [ ] Re-read every changed file; checked all call sites of any modified exports _(all changes)_ - [ ] Per-level discussion JSON exists with correct `discussionUrl` _(adventure/level changes only)_ diff --git a/.github/workflows/a11y-scan.yml b/.github/workflows/a11y-scan.yml index 119305c21..b3e69e8bb 100644 --- a/.github/workflows/a11y-scan.yml +++ b/.github/workflows/a11y-scan.yml @@ -25,6 +25,8 @@ jobs: - run: npm ci + - run: npm run sync + - run: npm run build - uses: actions/cache@v6 diff --git a/.github/workflows/add-discussion-url.yml b/.github/workflows/add-discussion-url.yml index 9bb8c8ef7..b9fa05b03 100644 --- a/.github/workflows/add-discussion-url.yml +++ b/.github/workflows/add-discussion-url.yml @@ -35,7 +35,7 @@ jobs: pull-requests: write steps: - - uses: actions/create-github-app-token@v3 + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 id: app-token with: client-id: ${{ vars.APP_ID }} @@ -102,8 +102,8 @@ jobs: DISCUSSION_URL: ${{ inputs.discussion_url }} run: node scripts/set-discussion-url.mjs - - name: Regenerate TypeScript - run: node scripts/generate-adventures.mjs + - name: Validate adventure YAML (Zod content schema) + run: npm run sync - name: Create pull request env: @@ -125,9 +125,6 @@ jobs: git add \ "src/data/adventures/${ADVENTURE_ID}/adventure.yaml" \ "src/data/adventures/${ADVENTURE_ID}/${LEVEL_ID}-posts.json" \ - "src/data/adventures/${ADVENTURE_ID}.generated.ts" \ - "src/data/adventures/index.ts" \ - "src/data/adventures/summaries.ts" \ "scripts/refresh-leaderboard.mjs" HAS_CHANGES=false @@ -154,14 +151,14 @@ jobs: - \`adventure.yaml\` — \`community_url\` set on the \`${LEVEL_ID}\` level; \`community_category_id\` set at adventure root if it was missing - \`${LEVEL_ID}-posts.json\` — \`discussionUrl\` set and initial posts fetched from Discourse - - Generated TypeScript updated (\`${ADVENTURE_ID}.generated.ts\`, \`index.ts\`, \`summaries.ts\`) - \`scripts/refresh-leaderboard.mjs\` — \`ADVENTURE_CATEGORIES\` patched with the resolved category ID + - (Astro reads the YAML directly — no generated TypeScript to update) ### Before merging - [ ] Verify the discussion URL is correct and the thread is publicly visible - [ ] Check that the posts JSON contains the expected content (or is an empty array if the thread is new) - - [ ] Run \`npm run lint && npm test && npm run build && npm run test:e2e\` locally + - [ ] Run \`npm run lint && npm run test:unit && npm run build && npm run test:e2e\` locally ### After merging diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1cd256da4..6cbc7b5ea 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,7 +9,7 @@ on: - cron: "0 2 * * *" permissions: - contents: write + contents: read # All jobs read-only by default; deploy job overrides to write concurrency: group: pages @@ -21,6 +21,8 @@ env: jobs: deploy: runs-on: ubuntu-latest + permissions: + contents: write # Required by JamesIves/github-pages-deploy-action to push gh-pages steps: - uses: actions/checkout@v7 @@ -29,17 +31,29 @@ jobs: node-version-file: .nvmrc cache: npm - - run: npm ci + - uses: actions/cache@v6 + id: npm-cache + with: + path: node_modules + key: node-modules-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - - run: npm run build + - if: steps.npm-cache.outputs.cache-hit != 'true' + run: npm ci - - run: cp dist/client/404/index.html dist/client/404.html + - run: npm run sync + - run: npm run lint + - run: npm run check + - run: npm run test:unit:coverage + + # Astro static build → dist/ (not dist/client/). Astro emits dist/404.html + # natively, so the old `cp .../404/index.html 404.html` step is gone. + - run: npm run build - name: Deploy to gh-pages - uses: JamesIves/github-pages-deploy-action@v4 + uses: JamesIves/github-pages-deploy-action@fa24774553152dd7873cd16ebd8d959b010c5445 # v4 with: branch: gh-pages - folder: dist/client + folder: dist force: false clean: true clean-exclude: | diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 7a948ccd1..0d17d6f86 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -12,7 +12,7 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: - lint: + check: if: github.event.action != 'closed' runs-on: ubuntu-latest steps: @@ -32,7 +32,12 @@ jobs: - if: steps.node-cache.outputs.cache-hit != 'true' run: npm ci + # Content validation gate: astro sync runs the Zod collection schema and + # fails on invalid adventure YAML. + - run: npm run sync - run: npm run lint + - run: npm run check + - run: npm run test:unit:coverage build: if: github.event.action != 'closed' @@ -54,14 +59,13 @@ jobs: - if: steps.node-cache.outputs.cache-hit != 'true' run: npm ci + - run: npm run sync - run: npm run build - - run: npm test - - uses: actions/upload-artifact@v7 with: - name: dist-client - path: dist/client/ + name: dist + path: dist/ retention-days: 1 e2e: @@ -91,8 +95,8 @@ jobs: - uses: actions/download-artifact@v7 with: - name: dist-client - path: dist/client/ + name: dist + path: dist/ - uses: actions/cache@v6 id: playwright-cache @@ -106,13 +110,12 @@ jobs: - if: steps.playwright-cache.outputs.cache-hit == 'true' run: npx playwright install-deps chromium + # playwright.config webServer runs `astro preview` (serves dist/) itself. - run: npm run test:e2e -- --shard=${{ matrix.shard }}/3 preview: - needs: [lint, e2e] - # Run when both passed (normal PR update), or when build was skipped (PR closed, cleanup). - # Never run when tests failed. - if: always() && ((needs.lint.result == 'success' && needs.e2e.result == 'success') || needs.lint.result == 'skipped') + needs: [check, e2e] + if: always() && ((needs.check.result == 'success' && needs.e2e.result == 'success') || needs.check.result == 'skipped') runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -132,27 +135,18 @@ jobs: - if: github.event.action != 'closed' && steps.node-cache.outputs.cache-hit != 'true' run: npm ci + # Astro's `base` (from VITE_BASE_PATH) prefixes every asset/link, and public/ + # assets are copied into dist/ automatically — so the whole dist/ deploys as + # the preview with no per-directory asset-copy step. Layout.astro marks + # /pr-preview/ builds noindex. - if: github.event.action != 'closed' - run: VITE_BASE_PATH=/pr-preview/pr-${{ github.event.number }}/ npm run build + run: npm run sync - if: github.event.action != 'closed' - run: | - PREVIEW_DIR="dist/client/pr-preview/pr-${{ github.event.number }}" - cp -r dist/client/assets "${PREVIEW_DIR}/" - cp -r dist/client/fonts "${PREVIEW_DIR}/" - cp -r dist/client/reveal "${PREVIEW_DIR}/" - cp -r dist/client/team "${PREVIEW_DIR}/" - cp -r dist/client/speakers "${PREVIEW_DIR}/" - cp -r dist/client/brand "${PREVIEW_DIR}/" - cp -r dist/client/solutions "${PREVIEW_DIR}/" - cp -r dist/client/qr "${PREVIEW_DIR}/" - cp -r dist/client/downloads "${PREVIEW_DIR}/" - cp -r dist/client/screenshots "${PREVIEW_DIR}/" - cp -r dist/client/deck "${PREVIEW_DIR}/" - cp -r dist/client/deck-template "${PREVIEW_DIR}/" - find dist/client -maxdepth 1 -type f \( -name "*.svg" -o -name "*.png" -o -name "*.ico" -o -name "*.webmanifest" -o -name "*.webp" \) -exec cp {} "${PREVIEW_DIR}/" \; - cp "${PREVIEW_DIR}/index.html" "${PREVIEW_DIR}/404.html" - - - uses: rossjrw/pr-preview-action@v1 + env: + PR_NUMBER: ${{ github.event.number }} + run: VITE_BASE_PATH=/pr-preview/pr-${PR_NUMBER}/ npm run build + + - uses: rossjrw/pr-preview-action@ffa7509e91a3ec8dfc2e5536c4d5c1acdf7a6de9 # v1 with: - source-dir: dist/client/pr-preview/pr-${{ github.event.number }}/ + source-dir: dist/ diff --git a/.github/workflows/refresh-community-data.yml b/.github/workflows/refresh-community-data.yml index 772697d77..7eb1c59b7 100644 --- a/.github/workflows/refresh-community-data.yml +++ b/.github/workflows/refresh-community-data.yml @@ -21,7 +21,7 @@ jobs: refresh: runs-on: ubuntu-latest steps: - - uses: actions/create-github-app-token@v3 + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 id: app-token with: client-id: ${{ vars.APP_ID }} @@ -96,14 +96,22 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh issue create \ - --title "Refresh community data: JSON validation failed" \ - --label "bug" \ - --body "The scheduled \`refresh-community-data\` workflow failed JSON structure validation and did not commit new data to \`main\`. + TITLE="Refresh community data: JSON validation failed" + OPEN=$(gh issue list \ + --state open \ + --search "\"${TITLE}\" in:title" \ + --json number \ + --jq 'length' 2>/dev/null || echo "0") + if [ "$OPEN" -eq 0 ]; then + gh issue create \ + --title "${TITLE}" \ + --label "bug" \ + --body "The scheduled \`refresh-community-data\` workflow failed JSON structure validation and did not commit new data to \`main\`. **Run:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} Check the run log for which file failed and which field was missing or malformed. Fix the upstream script that writes that file, then re-run the workflow manually via workflow_dispatch." + fi - name: Commit if changed id: commit diff --git a/.github/workflows/refresh-community-sitemap.yml b/.github/workflows/refresh-community-sitemap.yml index c4a93090c..16c0f865d 100644 --- a/.github/workflows/refresh-community-sitemap.yml +++ b/.github/workflows/refresh-community-sitemap.yml @@ -21,7 +21,7 @@ jobs: generate: runs-on: ubuntu-latest steps: - - uses: actions/create-github-app-token@v3 + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 id: app-token with: client-id: ${{ vars.APP_ID }} diff --git a/.github/workflows/reuse.yml b/.github/workflows/reuse.yml index f1238b0a7..9fdd5affe 100644 --- a/.github/workflows/reuse.yml +++ b/.github/workflows/reuse.yml @@ -14,4 +14,4 @@ jobs: steps: - uses: actions/checkout@v7 - name: REUSE lint - uses: fsfe/reuse-action@v6 + uses: fsfe/reuse-action@676e2d560c9a403aa252096d99fcab3e1132b0f5 # v6 diff --git a/.github/workflows/sync-adventure.yml b/.github/workflows/sync-adventure.yml index add531919..06022aaa3 100644 --- a/.github/workflows/sync-adventure.yml +++ b/.github/workflows/sync-adventure.yml @@ -5,7 +5,7 @@ on: inputs: adventure_url: description: > - GitHub URL of the adventure folder in the challenges repo — any branch works. + GitHub URL of the adventure folder in the challenges repo (any branch works). main: https://github.com/off-on-dev/open-source-challenges/tree/main/adventures/05-lex-imperfecta PR branch: https://github.com/off-on-dev/open-source-challenges/tree/feat/my-branch/adventures/05-lex-imperfecta required: true @@ -30,7 +30,7 @@ jobs: pull-requests: write steps: - - uses: actions/create-github-app-token@v3 + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 id: app-token with: client-id: ${{ vars.APP_ID }} @@ -73,7 +73,7 @@ jobs: if git show "origin/$BRANCH:${YAML_PATH}" > /tmp/pr-adventure.yaml 2>/dev/null && [ -s /tmp/pr-adventure.yaml ]; then mkdir -p "src/data/adventures/${SLUG}" cp /tmp/pr-adventure.yaml "$YAML_PATH" - echo "Restored adventure.yaml from PR branch ${BRANCH} — manual edits (discussion_url, topics, architecture_diagram, diagram_alt, deadline) will be preserved by sync-adventure.mjs" + echo "Restored adventure.yaml from PR branch ${BRANCH}; manual edits (discussion_url, topics, architecture_diagram, diagram_alt, deadline) will be preserved by sync-adventure.mjs" else echo "PR branch ${BRANCH} exists but has no adventure.yaml yet — starting fresh from main" fi @@ -88,14 +88,15 @@ jobs: LEVELS_TO_SYNC: ${{ inputs.levels }} run: node scripts/sync-adventure.mjs - - name: Validate and regenerate TypeScript - run: node scripts/generate-adventures.mjs + - name: Validate adventure YAML (Zod content schema) + run: npm run sync - name: Create pull request env: GH_TOKEN: ${{ steps.app-token.outputs.token }} ADVENTURE_URL: ${{ inputs.adventure_url }} run: | + set -euo pipefail SLUG=$(cat /tmp/adventure-slug) NAME=$(cat /tmp/adventure-name) LEVELS=$(cat /tmp/adventure-levels) @@ -112,19 +113,13 @@ jobs: git fetch origin "$BRANCH" 2>/dev/null || true git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH" + # Astro reads the YAML directly (Zod content collection) — no generated + # TS, prerender array, or sitemap region to commit. Routes come from + # getStaticPaths at build time. git add \ src/data/adventures/${SLUG}/ \ - src/data/adventures/${SLUG}.generated.ts \ - src/data/adventures/index.ts \ - src/data/adventures/summaries.ts \ - src/data/adventures/types.ts \ src/assets/diagrams/ \ - 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 + scripts/refresh-leaderboard.mjs git commit -m "feat: sync adventure ${SLUG} from challenges repo (${MODE})" # The branch is owned by this workflow, so a plain --force is safe here and @@ -132,7 +127,7 @@ jobs: git push --force origin "$BRANCH" if [ "$MODE" = "update" ]; then - PR_TITLE="feat(adventure): add levels to ${NAME} — ${LEVELS}" + PR_TITLE="feat(adventure): add levels to ${NAME}: ${LEVELS}" else PR_TITLE="feat(adventure): add ${NAME}" fi @@ -157,23 +152,18 @@ jobs: ### Manual steps (only if needed) - - [ ] If a level's \`architecture_diagram\` SVG was not auto-fetched (check the sync logs for a warning), add the SVG manually to \`src/assets/diagrams/\` and add \`architecture_diagram: <file>.svg\` to that level in \`adventure.yaml\`, then run \`npm run generate\` - - [ ] If a level has an \`architecture_diagram\` but no \`diagram_alt\`, add \`diagram_alt:\` to that level in \`adventure.yaml\` with a one-sentence description of the diagram, then run \`npm run generate\` - - [ ] Run \`node scripts/refresh-discussions.mjs\` after \`discussionUrl\` values are set + - [ ] If a level's \`architecture_diagram\` SVG was not auto-fetched (check the sync logs for a warning), add the SVG manually to \`src/assets/diagrams/\` and add \`architecture_diagram: <file>.svg\` to that level in \`adventure.yaml\` - [ ] If a level has an \`architecture_diagram\` but no \`diagram_alt\`, add \`diagram_alt:\` to that level in \`adventure.yaml\` with a one-sentence description of the diagram - [ ] Run \`node scripts/refresh-discussions.mjs\` after \`discussionUrl\` values are set - ### Auto-generated (do not edit by hand) + ### Routes & sitemap - The following are kept in sync with \`adventure.yaml\` by \`npm run generate\` (prebuild hook): - - \`public/sitemap.xml\` (GENERATED:adventures region) - - \`react-router.config.ts\` prerender (GENERATED:adventures region) - - \`e2e/smoke.spec.ts\` and \`src/test/seo.test.ts\` route arrays - - \`src/test/prerender.test.ts\` pages array - - \`scripts/refresh-leaderboard.mjs\` ADVENTURE_CATEGORIES + Routes are generated from \`adventure.yaml\` by the Astro content collection + at build time (\`getStaticPaths\`) — there is nothing to regenerate or commit. + Only \`scripts/refresh-leaderboard.mjs\` ADVENTURE_CATEGORIES is hand-maintained. ### Checks \`\`\`sh - npm run lint && npm test && npm run build && npm run test:e2e + npm run sync && npm run build && npm run test:e2e \`\`\` EOF else @@ -198,32 +188,26 @@ jobs: about: "One sentence bio." \`\`\` - [ ] Confirm \`month:\` is correct for the planned release - - [ ] Set \`community_category_id:\` in \`src/data/adventures/${SLUG}/adventure.yaml\` (look up at https://community.offon.dev/categories.json), then run \`npm run generate\` - - [ ] Update \`rewards.deadline:\` from \`TODO\` to ISO 8601 (e.g. \`2026-07-01T23:59:00+01:00\`) + - [ ] Set \`community_category_id:\` in \`src/data/adventures/${SLUG}/adventure.yaml\` (look up at https://community.offon.dev/categories.json) - [ ] Update \`rewards.deadline:\` from \`TODO\` to ISO 8601 (e.g. \`2026-07-01T23:59:00+01:00\`) - [ ] Review \`topics:\` on each level, auto-set to all adventure tags, refine to level-specific subset if needed - [ ] Update \`discussion_url:\` in each level once the Discourse threads are created - [ ] Update \`discussionUrl\` in each \`*-posts.json\` stub ### Manual steps (only if needed) - - [ ] If a level's \`architecture_diagram\` SVG was not auto-fetched (check the sync logs for a warning), add the SVG manually to \`src/assets/diagrams/\` and add \`architecture_diagram: <file>.svg\` to that level in \`adventure.yaml\`, then run \`npm run generate\` - - [ ] If a level has an \`architecture_diagram\` but no \`diagram_alt\`, add \`diagram_alt:\` to that level in \`adventure.yaml\` with a one-sentence description of the diagram, then run \`npm run generate\` - - [ ] Run \`node scripts/refresh-leaderboard.mjs\` after \`community_category_id\` is set + - [ ] If a level's \`architecture_diagram\` SVG was not auto-fetched (check the sync logs for a warning), add the SVG manually to \`src/assets/diagrams/\` and add \`architecture_diagram: <file>.svg\` to that level in \`adventure.yaml\` - [ ] If a level has an \`architecture_diagram\` but no \`diagram_alt\`, add \`diagram_alt:\` to that level in \`adventure.yaml\` with a one-sentence description of the diagram - [ ] Run \`node scripts/refresh-leaderboard.mjs\` after \`community_category_id\` is set - [ ] Run \`node scripts/refresh-discussions.mjs\` after \`discussionUrl\` values are set - ### Auto-generated (do not edit by hand) + ### Routes & sitemap - The following are kept in sync with \`adventure.yaml\` by \`npm run generate\` (prebuild hook): - - \`public/sitemap.xml\` (GENERATED:adventures region) - - \`react-router.config.ts\` prerender (GENERATED:adventures region) - - \`e2e/smoke.spec.ts\` and \`src/test/seo.test.ts\` route arrays - - \`src/test/prerender.test.ts\` pages array - - \`scripts/refresh-leaderboard.mjs\` ADVENTURE_CATEGORIES + Routes are generated from \`adventure.yaml\` by the Astro content collection + at build time (\`getStaticPaths\`) — there is nothing to regenerate or commit. + Only \`scripts/refresh-leaderboard.mjs\` ADVENTURE_CATEGORIES is hand-maintained. ### Checks \`\`\`sh - npm run lint && npm test && npm run build && npm run test:e2e + npm run sync && npm run build && npm run test:e2e \`\`\` EOF fi diff --git a/.github/workflows/validate-adventures.yml b/.github/workflows/validate-adventures.yml index a540a6723..988c4e5d7 100644 --- a/.github/workflows/validate-adventures.yml +++ b/.github/workflows/validate-adventures.yml @@ -5,9 +5,7 @@ on: paths: - 'src/data/adventures/**' - 'src/data/solutions/**' - - 'react-router.config.ts' - - 'public/sitemap.xml' - - 'schemas/adventure.schema.json' + - 'src/content.config.ts' permissions: contents: read @@ -37,110 +35,39 @@ jobs: - if: steps.node-cache.outputs.cache-hit != 'true' run: npm ci - - name: Validate YAML schema - run: node scripts/generate-adventures.mjs --validate-only - - - name: Validate solution index is up-to-date - run: node scripts/generate-solutions.mjs --validate-only - - - name: Verify generated files are up-to-date - run: | - node scripts/generate-adventures.mjs - node scripts/generate-solutions.mjs - GENERATED_PATHS=( - src/data/adventures/ - src/data/solutions/index.ts - src/data/solutions/manifest.ts - public/llms.txt - 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 - ) - if ! git diff --quiet "${GENERATED_PATHS[@]}"; then - echo "❌ Generated files are out of date. Run 'npm run generate' and 'npm run generate:solutions' and commit the result." - git diff --stat "${GENERATED_PATHS[@]}" - exit 1 - fi + # astro sync loads the content collection and runs the Zod schema over every + # adventure.yaml. Invalid YAML (unknown field, bad enum, missing required) + # fails here — this replaces the old ajv `generate-adventures.mjs --validate-only`. + - name: Validate adventure YAML (Zod content schema) + run: npm run sync - name: Validate adventure consistency run: | set -euo pipefail - ERRORS=0 - - # Extract adventure IDs from YAML files for adventure_dir in src/data/adventures/*/; do adventure_id=$(basename "$adventure_dir") - - # Skip if no adventure.yaml - if [[ ! -f "$adventure_dir/adventure.yaml" ]]; then - continue - fi - + [[ -f "$adventure_dir/adventure.yaml" ]] || continue echo "Checking adventure: $adventure_id" - # Check generated TS file exists - if [[ ! -f "src/data/adventures/$adventure_id.generated.ts" ]]; then - echo " ❌ Missing generated file: src/data/adventures/$adventure_id.generated.ts" - ERRORS=$((ERRORS + 1)) - fi - - # Check adventure is imported in index.ts - if ! grep -q "from \"./$adventure_id.generated\"" src/data/adventures/index.ts; then - echo " ❌ Not imported in index.ts" - ERRORS=$((ERRORS + 1)) - fi - # Live level IDs parsed via the yaml package (avoids fragile awk line-matching) level_ids=$(YAML_PATH="$adventure_dir/adventure.yaml" node -e "const y=require('yaml'),f=require('fs');(y.parse(f.readFileSync(process.env.YAML_PATH,'utf8')).levels||[]).forEach(l=>console.log(l.level));") - # Check adventure landing page in prerender - if ! grep -q "\"/adventures/$adventure_id\"" react-router.config.ts; then - echo " ❌ /adventures/$adventure_id missing from prerender in react-router.config.ts" - ERRORS=$((ERRORS + 1)) - fi - - # Check adventure landing page in sitemap - if ! grep -q "offon.dev/adventures/$adventure_id/" public/sitemap.xml; then - echo " ❌ /adventures/$adventure_id/ missing from sitemap.xml" - ERRORS=$((ERRORS + 1)) - fi - - # Check each level for level_id in $level_ids; do - # Discussion JSON exists - if [[ ! -f "src/data/adventures/$adventure_id/$level_id-posts.json" ]]; then - echo " ❌ Missing discussion JSON: src/data/adventures/$adventure_id/$level_id-posts.json" - ERRORS=$((ERRORS + 1)) - fi - - # Prerender entry exists - if ! grep -q "\"/adventures/$adventure_id/levels/$level_id\"" react-router.config.ts; then - echo " ❌ /adventures/$adventure_id/levels/$level_id missing from prerender" - ERRORS=$((ERRORS + 1)) - fi - - # Sitemap entry exists - if ! grep -q "offon.dev/adventures/$adventure_id/levels/$level_id/" public/sitemap.xml; then - echo " ❌ /adventures/$adventure_id/levels/$level_id/ missing from sitemap.xml" + # A discussion JSON file must exist for each live level. + if [[ ! -f "$adventure_dir/$level_id-posts.json" ]]; then + echo " ❌ Missing discussion JSON: $adventure_dir$level_id-posts.json" ERRORS=$((ERRORS + 1)) fi done - echo " ✓ Done" done - if [[ $ERRORS -gt 0 ]]; then echo "" - echo "❌ Found $ERRORS consistency error(s). Run 'npm run generate' to regenerate route/sitemap/prerender regions, or re-run the Sync Adventure workflow." + echo "❌ Found $ERRORS consistency error(s)." exit 1 - else - echo "" - echo "✓ All adventures are consistent." fi + echo "✓ All adventures are consistent." - name: Verify all adventures are registered in ADVENTURE_CATEGORIES run: | @@ -148,9 +75,7 @@ jobs: ERRORS=0 for adventure_dir in src/data/adventures/*/; do adventure_id=$(basename "$adventure_dir") - if [[ ! -f "$adventure_dir/adventure.yaml" ]]; then - continue - fi + [[ -f "$adventure_dir/adventure.yaml" ]] || continue if ! grep -q "\"$adventure_id\"" scripts/refresh-leaderboard.mjs; then echo "❌ '$adventure_id' is missing from ADVENTURE_CATEGORIES in scripts/refresh-leaderboard.mjs" ERRORS=$((ERRORS + 1)) @@ -160,6 +85,5 @@ jobs: echo "" echo "❌ Add missing adventure IDs to ADVENTURE_CATEGORIES in scripts/refresh-leaderboard.mjs before merging." exit 1 - else - echo "✓ All adventure IDs are registered in ADVENTURE_CATEGORIES." fi + echo "✓ All adventure IDs are registered in ADVENTURE_CATEGORIES." diff --git a/.github/workflows/validate-docs.yml b/.github/workflows/validate-docs.yml index 0a0b35cfe..9993045b8 100644 --- a/.github/workflows/validate-docs.yml +++ b/.github/workflows/validate-docs.yml @@ -4,9 +4,9 @@ on: pull_request: paths: - 'src/components/**' - - 'src/hooks/**' - 'src/lib/**' - - 'src/data/constants.ts' + - 'src/stores/**' + - 'src/pages/**' - 'package.json' - '.github/workflows/*.yml' diff --git a/.gitignore b/.gitignore index 74caff3bb..0a8279040 100644 --- a/.gitignore +++ b/.gitignore @@ -13,12 +13,8 @@ lerna-debug.log* node_modules dist -dist-ssr *.local -# React Router v7 generated types -.react-router/ - # Playwright test-results/ playwright-report/ @@ -42,3 +38,9 @@ tmp/ *.njsproj *.sln *.sw? + +# Astro +.astro/ +test-results/ +playwright-report/ +.vite/ diff --git a/ACCESSIBILITY.md b/ACCESSIBILITY.md index beab826f3..a0db5df1e 100644 --- a/ACCESSIBILITY.md +++ b/ACCESSIBILITY.md @@ -30,8 +30,9 @@ The following WCAG 2.2 Level AAA criteria are actively targeted on this site: - Skip-to-content link as the first focusable element on every page. - Visible focus rings on all interactive elements, in both light and dark mode. -- Semantic landmarks: one `<main id="main-content">`, plus `<nav>`, `<header>`, `<footer>`, `<section>`, and `<article>` where appropriate. -- One `<h1>` per page with no skipped heading levels. +- Semantic landmarks: one `<main id="main-content">`, plus `<nav>`, `<header>`, `<footer>`, `<section>`, and `<article>` where appropriate. The site-wide `<nav aria-label="Main">` in `Navbar.astro` is wrapped in `<header>` so it provides the banner landmark. Never place the primary nav outside a `<header>` element. +- Heading rules: one `<h1>` per page with no skipped heading levels. Never place a heading element (`<h1>`-`<h6>`) inside a `<summary>` element (which has `role="button"`); the ARIA spec forbids heading semantics in a button role. Use a screen-reader-only heading before `<details>` for document outline, and a `<span>` inside `<summary>` for the visible label. +- One `<h1>` per page with no skipped heading levels (see heading rules above). - Meaningful `alt` text on informational images, empty `alt=""` paired with `aria-hidden="true"` on decorative ones. - Screen reader announcement of links that open in a new tab. - Color contrast verified at 7:1 for body text and 4.5:1 for large text (both WCAG AAA), and 3:1 for UI controls, in both modes. @@ -63,8 +64,8 @@ If you find a barrier that is not listed here, please report it using the link b ### Automated -- **axe-core via Playwright** on every pull request, configured in [`e2e/smoke.spec.ts`](e2e/smoke.spec.ts). Runs in both dark and light mode against the production build with tags `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`, `wcag22aa`, and `best-practice`. The PR preview workflow blocks on these scans. Never reduce this tag set. -- **Vitest** assertions on landmark roles, labels, and focus behavior for components and hooks ([`src/test/`](src/test/)). +- **axe-core via Playwright** on every pull request, configured in [`e2e/a11y.spec.ts`](e2e/a11y.spec.ts). Runs in both dark and light mode against the production build with tags `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`, `wcag22aa`, and `best-practice`. The PR preview workflow blocks on these scans. Never reduce this tag set. +- Automated tests are Playwright-only (`e2e/`). Unit tests for library logic are a known gap. Automated axe passes are necessary but not sufficient. Automated tools catch roughly 30–40% of real-world accessibility issues. Manual testing is required for every interactive component. @@ -270,7 +271,7 @@ Use the correct keys for each control type: - Never apply overline/label typography (`text-sm uppercase tracking-widest`) to a heading tag. If the text is a genuine section heading, give it heading-appropriate typography. If it is purely decorative, use `<span>` or `<p>`. - Never use a non-heading tag for text visually styled as a heading. Promote it to the correct heading level. - Every page's primary content must live inside a single `<main id="main-content">`. Do not split content across multiple `<main>` elements. -- `<html lang="en">` is set in `src/root.tsx`. Never remove or change it. If a page includes content in another language, add `lang` to that element. +- `<html lang="en">` is set in `src/layouts/Layout.astro`. Never remove or change it. If a page includes content in another language, add `lang` to that element. - Tables must include `<caption>` or `aria-label`, and header cells must use `scope="col"` or `scope="row"`. ### Images and media @@ -283,7 +284,7 @@ Use the correct keys for each control type: ### External links -- Every `<a target="_blank">` must reference the shared new-tab hint with `aria-describedby="new-tab-hint"`. A single hidden `<span id="new-tab-hint" hidden>opens in a new tab</span>` is rendered once in `Layout.tsx`. Do not fold "opens in a new tab" into the link text or `aria-label`, and do not add a per-link `sr-only` span — the hint is an accessible description, not part of the name (padding the name breaks voice control, WCAG 2.5.3 Label in Name). +- Every `<a target="_blank">` must reference the shared new-tab hint with `aria-describedby="new-tab-hint"`. A single hidden `<span id="new-tab-hint" hidden>opens in a new tab</span>` is rendered once in `Layout.astro`. Do not fold "opens in a new tab" into the link text or `aria-label`, and do not add a per-link `sr-only` span — the hint is an accessible description, not part of the name (padding the name breaks voice control, WCAG 2.5.3 Label in Name). - Never render a non-navigable URL as a link. Loopback / localhost / single-label hosts (e.g. `http://localhost:8080/`) must be plain text — on the deployed site a link there points at the visitor's own machine. The adventure generator (`annotateExternalLinks`) unwraps these automatically; in JSX, write them as text, not `<a>`. ### Links @@ -314,8 +315,8 @@ Use the correct keys for each control type: ``` - Never use `aria-label` directly on `<svg>`. Support across assistive technologies is inconsistent. Use the `<title>` + `aria-labelledby` pattern instead. -- For lucide-react icons: always pass `aria-hidden={true}` when the icon is decorative (next to visible text). For icon-only buttons, put `aria-label` on the parent `<button>` or `<a>`, not on the `<svg>`. -- Brand SVGs (e.g. LinkedIn in `Footer.tsx`): set `aria-hidden="true"` on the `<svg>` and `aria-label` on the parent interactive element. Use `fill="currentColor"` so hover and theme color changes apply. See the Icons section of `styleguide.md`. +- For icons from `unplugin-icons` (lucide set): always pass `aria-hidden={true}` when the icon is decorative (next to visible text). For icon-only buttons, put `aria-label` on the parent `<button>` or `<a>`, not on the `<svg>`. +- Brand SVGs (e.g. LinkedIn in `Footer.astro`): set `aria-hidden="true"` on the `<svg>` and `aria-label` on the parent interactive element. Use `fill="currentColor"` so hover and theme color changes apply. See the Icons section of `styleguide.md`. ### ARIA @@ -331,34 +332,16 @@ Use the correct keys for each control type: ### Tooltips -This site uses Radix UI's `<Tooltip>` primitive (via `src/components/ui/tooltip.tsx`). Radix manages `role="tooltip"` and `aria-describedby` automatically when the component is wired up correctly. The rules below cover the cases Radix does not handle for you. +The tooltip uses a custom `abbr[data-title]` implementation — at build time, `src/lib/markdown-pipeline.mjs` rewrites `<abbr title="...">` elements to use `data-title` attributes, and `src/layouts/Layout.astro` includes a `position:fixed` JS portal that displays the tooltip text on hover/focus, clamped to the viewport. -- Always wrap the usage site in `<TooltipProvider>`. Do not mount `<TooltipProvider>` globally in `Layout.tsx`; wrap only the subtree that uses `<Tooltip>`. -- `<TooltipTrigger>` must wrap a real interactive element (`<button>`, `<a>`, or a component that renders one). Never put a non-interactive element like `<span>` or `<div>` as the direct trigger child; screen readers will not announce the tooltip. -- Never put interactive content (buttons, links) inside `<TooltipContent>`. Tooltips are not reachable by touch or keyboard-only users and cannot contain their own focusable children. -- Tooltips must not be the only means of conveying critical information. If the tooltip text is essential to understanding or operating the trigger, surface it as visible text, a label, or an accessible description instead. -- Test that the tooltip appears on both `:hover` and `:focus-visible`. Radix handles this by default; do not override the `defaultOpen`/`open` props in a way that breaks focus triggering. -- Mobile: there is no hover on touch screens. If the tooltip content is not exposed any other way, add visible text or an `aria-label` on the trigger as a fallback. - -#### WCAG 1.4.13 requirements for all tooltips (Radix and CSS) +#### WCAG 1.4.13 requirements for all tooltips (JS portal and CSS) All tooltip implementations on this site must satisfy three conditions: -1. **Dismissible:** pressing `Escape` must close the tooltip without moving keyboard focus. The `<Abbr>` component handles this via `onKeyDown`. CSS-only tooltips (`.md-inline abbr`) cannot dismiss on `Escape`; this is a known limitation of the CSS path. -2. **Hoverable:** the cursor must be able to move from the trigger onto the tooltip without the tooltip closing. Both `<Abbr>` (via transparent padding bridge) and `.md-inline abbr` (via transparent `border-bottom` bridge) satisfy this. Never add `pointer-events: none` to a tooltip element that users are expected to read. +1. **Dismissible:** pressing `Escape` must close the tooltip without moving keyboard focus. The `position:fixed` JS portal handles this via a keydown listener. CSS-only tooltips (`.md-inline abbr`) cannot dismiss on `Escape`; this is a known limitation of the CSS path. +2. **Hoverable:** the cursor must be able to move from the trigger onto the tooltip without the tooltip closing. The JS portal satisfies this. Never add `pointer-events: none` to a tooltip element that users are expected to read. 3. **Persistent:** the tooltip must remain open as long as pointer or focus is within its bounds. -#### Abbreviation tooltips (`<Abbr>` component) - -Use `<Abbr title="Full expansion">ABBR</Abbr>` (from `src/components/Abbr.tsx`) for abbreviations in JSX pages and components. - -Both the `<Abbr>` component and prose `<abbr>` render identical markup and share **one** tooltip implementation, the `useAbbrTooltips` hook (`src/hooks/useAbbrTooltips.ts`). - -- **Accessible name and expansion.** The `<abbr>` carries `data-title` (the visual tooltip text) and `aria-describedby` pointing to an adjacent sr-only `<span>` holding the expansion. The visible token (e.g. "PR") stays the accessible name; the expansion is a description (WCAG 2.5.3). Neither `title` (would trigger the browser's native tooltip) nor `aria-label` (would replace the visible token) is used. -- **Focusable trigger.** `tabIndex={0}` makes the abbreviation reachable by keyboard and touch. The hook reveals the tooltip on hover and focus, forces focus on click (iOS taps), and hides it on Escape **without moving focus** (WCAG 1.4.13 dismissible). The eslint rule `no-noninteractive-tabindex` is suppressed intentionally. -- **Component vs prose.** Use `<Abbr title="…">ABBR</Abbr>` in JSX; use native `<abbr title="…">` in YAML/markdown prose. The generator (`scripts/generate-adventures.mjs`) rewrites the latter to `data-title` + `tabindex` + the `aria-describedby` sr-only span at build time; `MarkdownContent` and `<Abbr>` both call `useAbbrTooltips` to wire the tooltip. The global `abbr[data-title]::after` CSS rule is the no-JS fallback. -- **Never nest an `<abbr>` tooltip inside an `<a>` or `<button>`.** A focusable `<abbr>` inside an interactive element is invalid (interactive-in-interactive). For pre-rendered prose that lands in an interactive container (card links, walkthrough step buttons), `stripLinks` removes `tabindex` and `aria-describedby` so the embedded `<abbr>` degrades to a non-focusable hover-only `data-title` tooltip. - ### Forms - Every `<input>`, `<select>`, and `<textarea>` must have an associated `<label>` via `for`/`id` pairing or `aria-label`. diff --git a/ADVENTURES.md b/ADVENTURES.md index 6d83adcbd..d6aa1e176 100644 --- a/ADVENTURES.md +++ b/ADVENTURES.md @@ -1,248 +1,28 @@ # Adventures -This file covers the full adventure workflow: **challenge authors** working in the challenges repo, **website reviewers** completing the PR checklist after a sync, and **solution contributors** adding post-challenge walkthroughs. +This file is for anyone creating, syncing, or updating an adventure on offon.dev. -Adventures live in a separate repo ([open-source-challenges](https://github.com/off-on-dev/open-source-challenges)) and are pulled into this site via the **Sync Adventure** GitHub Actions workflow. You never write the generated TypeScript files by hand; the workflow and build scripts do that automatically. +Adventures live in a separate repo ([open-source-challenges](https://github.com/off-on-dev/open-source-challenges)) and are pulled into this site via the **Sync Adventure** GitHub Actions workflow. -**Jump to:** +Since the Astro migration there is **no code generation step**. Astro reads `adventure.yaml` directly through a Zod-validated content collection (`src/content.config.ts`) and renders the markdown prose to HTML at build time. Routes appear automatically via `getStaticPaths()`. The source of truth is the YAML; there are no `*.generated.ts` files. -- [YAML Templates](#yaml-templates): full field reference for challenge authors -- [Syncing a New Adventure](#syncing-a-new-adventure): trigger the workflow -- [Completing the PR Checklist](#completing-the-pr-checklist): what to do after the sync -- [Architecture Diagrams](#architecture-diagrams): SVG, ASCII art, and prose fields -- [Re-syncing an Open PR](#re-syncing-an-open-pr): updating an in-progress PR -- [Adding a New Level to an Already-Merged Adventure](#adding-a-new-level-to-an-already-merged-adventure): promoting a Coming Soon level -- [Adding a Solution Walkthrough](#adding-a-solution-walkthrough): post-challenge write-ups -- [Workflows at a Glance](#workflows-at-a-glance): quick reference for all GitHub Actions workflows -- [Refresh Scripts](#refresh-scripts): running data refresh scripts locally +> **Slug constraint:** The `slug` field in `adventure.yaml` must exactly match the adventure directory name under `src/data/adventures/`. The content loader asserts this at build time. If they diverge, the build fails with a clear message. When renaming a directory, update the YAML `slug` field to match (or vice versa). --- ## How the Content Pipeline Works ```text -Step 1 — Sync Adventure workflow (triggered manually via GitHub Actions): - - off-on-dev/open-source-challenges offon.dev website repo - adventures/<id>/docs/ - index.yaml ─┐ src/data/adventures/<slug>/adventure.yaml - beginner.yaml ├──────────────► src/data/adventures/<slug>/<level>-posts.json (stubs) - intermediate.yaml ┘ src/assets/diagrams/<slug>-<level>.svg (if present) - diagrams/*.svg ─┘ - -Step 2 — npm run generate (prebuild, runs automatically before every build): - - src/data/adventures/<slug>/adventure.yaml - ────────────────────────────────────────► src/data/adventures/<slug>.generated.ts - src/data/adventures/index.ts - src/data/adventures/summaries.ts - public/sitemap.xml (adventure + tag entries) +off-on-dev/open-source-challenges offon.dev website repo + adventures/<id>/docs/ + index.yaml ──── Sync Adventure workflow ────► src/data/adventures/<slug>/adventure.yaml + beginner.yaml src/data/adventures/<slug>/<level>-posts.json + intermediate.yaml + ... + (build time) content collection ──► routes + rendered HTML (getStaticPaths) ``` -The sync workflow also regenerates `public/sitemap.xml`, `react-router.config.ts`, `e2e/smoke.spec.ts`, `src/test/seo.test.ts`, `src/test/prerender.test.ts`, and `scripts/refresh-leaderboard.mjs`. All of these appear in the PR diff; they are managed automatically and should not be edited by hand. - -The generated TypeScript files are committed so the dev server works without running the generator manually. Never edit `*.generated.ts`, `index.ts`, or `summaries.ts` by hand. - ---- - -## YAML Templates - -Full field reference for challenge authors. All fields are shown with example values. Remove any that do not apply to your adventure. - -### `docs/index.yaml` (adventure-level metadata) - -```yaml -# Title of the adventure. Use `title` (preferred) or `name`. -title: "My Adventure Title" -emoji: 🚀 - -# Optional: Lucide icon name to use instead of the emoji icon. -# Accepts any valid Lucide icon name (e.g. "Shield", "Cpu", "GitBranch"). -# icon: Shield - -# Tags drive the tag-filter UI and default `topics` for each level. -# Use the canonical tool/platform names shown on the OffOn website. -tags: - - Kubernetes - - Argo CD - - Helm - -# Optional: overrides the auto-generated SEO meta description for the adventure page. -# Keep under 160 characters. -# meta_description: "Fix broken Kyverno policies to restore proper admission control." - -# Optional: one-paragraph card summary shown on the adventure card on the home page. -# Plain text only — no markdown. If omitted, derived from backstory[0]. -# story: "Fix broken GitOps setups across multiple environments and restore the services." - -# One or more story paragraphs. Markdown is supported. -backstory: - - "Opening paragraph that sets the scene." - - "Second paragraph continuing the story." - -# Optional: an overview of the challenge shown before the story. -# Useful when backstory is long and reviewers need a quick summary. -overview: - - "Brief, direct summary of what the participant will fix or build." - -rewards: - # ISO 8601 or human-readable: "Tuesday, 1 July 2026 at 23:59 CET" - # Supported TZ abbreviations: CET (+01:00), CEST (+02:00), UTC, GMT - deadline: "2026-09-01T23:59:00+01:00" - tiers: - - label: 1st place - description: 50% voucher for a Linux Foundation certification - - label: Top 3 - description: Credly badge to showcase the achievement - # Optional: overrides the default eligibility text on the rewards card. - # eligibility: "Open to all registered participants who submit before the deadline." - # Optional: overrides the default ranking note on the rewards card. - # ranking_note: "Ranked by verification timestamp; ties broken by submission order." -``` - ---- - -### `docs/<level>.yaml` (level content) - -One file per level: `beginner.yaml`, `intermediate.yaml`, `expert.yaml`. - -```yaml -# Required. Must match the filename: beginner | intermediate | expert -level: beginner -emoji: 🟢 # 🟢 beginner 🟡 intermediate 🔴 expert -title: "Level Title" - -# Devcontainer folder name in off-on-dev/open-source-challenges/.devcontainer/ -# The generator auto-corrects this if it finds an unambiguous match. -devcontainer: my-adventure_beginner - -# Optional: upgrade the default Codespace machine size. -# Only set this when the level genuinely needs more RAM or CPU. -codespaces_machine: 4core - -# Optional: estimated completion time shown as a pill on the level card. -estimated_time: "2-3 hours" - -# One sentence shown on the adventure card and the level sidebar. -summary: "Fix the broken X so that Y works end to end." - -# Who this level is for. Markdown, inline code, and <abbr> are supported. -# Use <abbr title="full term">ABBR</abbr> for acronyms on first use. -audience: >- - Platform engineers, <abbr title="Site Reliability Engineers">SREs</abbr>, and developers - curious about X. No prior experience needed, but familiarity with basic - `kubectl` and YAML will help. - -# Optional: a short hook shown at the top of the level page, before the story. -# hook: "The cluster is on fire and the policies that should protect it are broken." - -# Optional: the in-world scenario framing the level's context. -# scenario: "You have been granted emergency access to the broken cluster." - -# Level-specific story paragraphs. Markdown is supported. -backstory: - - "What went wrong and why it matters." - - "What the participant's role is in fixing it." - -# Bullet-point acceptance criteria. Markdown is supported. -# Keep each item concrete and testable. Use **bold** to call out key terms. -objective: - - "All workloads **missing the `required-label`** are blocked at admission." - - "All verification checks pass." - -# What skills and concepts the participant will practise. -# Use [linked text](url) for official docs. Use `backticks` for tool names. -what_you_learn: - - "How [X](https://example.com/docs) works and why it matters." - - "How to use `kubectl` logs to trace a silent failure across tools." - -# Architecture explanation. Shown under the Architecture heading on the level page. -# Use an array; each item becomes a separate prose block. -architecture: - - "High-level description of the system the participant is working in." - - "Which files or resources they need to touch, and which to leave alone." - -# Optional: SVG architecture diagram. -# Place the SVG at docs/diagrams/<slug>-<level>.svg in the challenges repo. -# The sync auto-fetches it. Name must match the filename exactly. -architecture_diagram: "my-adventure-beginner.svg" -diagram_alt: "Left-to-right diagram showing how X connects to Y and Z." - -# Optional: ASCII art fallback when no SVG is available. -# Use a YAML block scalar (|) to preserve whitespace and line breaks. -# architecture_ascii: | -# ┌──────────┐ ┌──────────┐ -# │ Client │──────►│ API │ -# └──────────┘ └──────────┘ - -# Tools the participant will use. Shown as a toolbox on the level page. -toolbox: - - name: Tool Name - url: https://example.com/docs - description: "What it does in this challenge and how to open it." - -# Optional: running services exposed on local ports (Codespace / devcontainer). -# Omit if there are no local services. -services: - - name: My Service - port: 8080 - credentials: admin / password # omit if no login required - description: "What this service is and what to look for in it." - -# Step-by-step guide shown in the How to Play tab. -# Markdown, inline code, <abbr>, and fenced code blocks are supported in both -# `title` and `content`. The `id` field is informational only; it is not used -# by the generator or the website. -how_to_play: - - id: start - title: "Start the Environment" - content: | - Start the platform with `make start`. The first run may take ~30-60 seconds - to pull images. Once it's up, leave it running in that terminal. - - id: explore - title: "Explore the Setup" - content: | - Open the <abbr title="Command Line Interface">CLI</abbr> and inspect the - running resources: - - ```bash - kubectl get pods -A - ``` - - Look at what is deployed and note anything that looks broken or missing. - - id: fix - title: "Fix It" - content: | - The bug lives in `path/to/file.yaml`. Edit it directly and re-apply: - - ```bash - kubectl apply -f path/to/file.yaml - ``` - - When you think it's fixed, run the verification script: - - ```bash - make verify - ``` - -# Optional: further reading shown at the bottom of the level page. -helpful_links: - - title: "Official Docs: Feature Name" - url: https://example.com/docs/feature - description: "One sentence on why this link is useful for this challenge." - -# Optional: refine the default topics (which default to all adventure tags). -# Only set this when the level uses a subset of the adventure's tools. -# topics: -# - Kubernetes -# - Argo CD - -# Optional: override the verification step shown at the end of How to Play. -# Omit to use the standard verify.sh description. -# verification: -# command: make verify -# description: "What the script checks and what a passing result looks like." -``` +Validate the YAML any time with `npm run sync` (runs the Zod schema; the build also fails on invalid content). --- @@ -254,25 +34,25 @@ Go to **Actions → Sync Adventure from Challenges Repo → Run workflow**. | Input | Required | Description | | --- | --- | --- | -| `adventure_url` | Yes | GitHub URL of the adventure folder. Any branch works. Main: `https://github.com/off-on-dev/open-source-challenges/tree/main/adventures/05-lex-imperfecta`. PR branch: `https://github.com/off-on-dev/open-source-challenges/tree/feat/my-branch/adventures/05-lex-imperfecta`. | +| `adventure_url` | Yes | GitHub URL of the adventure folder — any branch works. Main: `https://github.com/off-on-dev/open-source-challenges/tree/main/adventures/05-lex-imperfecta`. PR branch: `https://github.com/off-on-dev/open-source-challenges/tree/feat/my-branch/adventures/05-lex-imperfecta`. | | `levels` | No | Comma-separated level IDs to make live now (e.g. `beginner` or `beginner,intermediate`). Levels that exist in the challenges repo but are not listed here appear as "Coming Soon" placeholders. Leave blank to make all levels live. | ### 2. What the workflow does 1. Validates the URL points to `off-on-dev/open-source-challenges`. 2. If a PR branch (`feat/adventure-<slug>`) already exists, restores `adventure.yaml` from that branch so any manual edits already made survive the re-sync. -3. Fetches `docs/index.yaml` and all level YAMLs from the challenges repo. For any level with `architecture_diagram` set, auto-fetches the SVG from `docs/diagrams/` and writes it to `src/assets/diagrams/`. +3. Fetches `docs/index.yaml` and all level YAMLs from the challenges repo. 4. Writes `src/data/adventures/<slug>/adventure.yaml` and creates `<level>-posts.json` stubs for each new live level. -5. Runs `generate-adventures.mjs` to regenerate TypeScript, sitemap entries, prerender entries, and test arrays. +5. Validates the YAML with `astro sync` (Zod content schema) and registers the adventure in `ADVENTURE_CATEGORIES` (`scripts/refresh-leaderboard.mjs`). Routes and sitemap entries are automatic via `getStaticPaths()` and `src/pages/sitemap.xml.ts`. `public/llms.txt` is updated by hand as part of the PR checklist. 6. Opens (or updates) a PR on `feat/adventure-<slug>` with a checklist of steps to complete before merging. --- ## Completing the PR Checklist -The PR body lists everything that needs to happen before merging. Complete the items in order; step 8 (leaderboard) requires step 3 (`community_category_id`) to be set first. +The PR body lists everything that needs to happen before merging. Here is each item explained. -### 1. Add contributor block +### Add contributor block ```yaml contributor: @@ -281,45 +61,40 @@ contributor: about: "One sentence bio." ``` -Add this to `src/data/adventures/<slug>/adventure.yaml`. The `url` and `about` fields are optional but recommended. This block survives all future re-syncs once set. +Add this to `src/data/adventures/<slug>/adventure.yaml`. The `url` and `about` fields are optional but recommended. Once set, this block survives future re-syncs automatically. -### 2. Confirm month +### Confirm month -The `month:` field defaults to the current month when first synced. Correct it if the adventure is planned for a future release. Format: `MMM YYYY` (e.g. `JAN 2026`). This field survives all future re-syncs once set. +The `month:` field defaults to the current month when first synced. Correct it if the adventure is planned for a future release. Format: `MMM YYYY` (e.g. `JAN 2026`). This field also survives re-syncs once set. -### 3. Set community_category_id +### Set community_category_id 1. Look up the Discourse category at `https://community.offon.dev/categories.json`. 2. Find the category for this adventure and copy its `id` integer. 3. Add `community_category_id: <id>` to `adventure.yaml`. -4. Run `npm run generate` to regenerate TypeScript. +4. Run `npm run sync` to validate the YAML against the content schema. -This field survives all future re-syncs once set. +This field also survives future re-syncs once set. -### 4. Update rewards deadline +### Update rewards deadline -Change `rewards.deadline` from `TODO` to an ISO 8601 datetime or the human-readable format accepted by the generator: +Change `rewards.deadline:` from `TODO` to either an ISO 8601 datetime or the human-readable format used in the challenges repo: ```yaml -rewards: - # ISO 8601 (preferred) - deadline: "2026-07-01T23:59:00+01:00" +# ISO 8601 (preferred for direct edits) +rewards.deadline: "2026-07-01T23:59:00+01:00" - # Human-readable (the generator converts it automatically) - # deadline: "Tuesday, 1 July 2026 at 23:59 CET" +# Human-readable (accepted; the generator converts it automatically) +rewards.deadline: "Tuesday, 1 July 2026 at 23:59 CET" ``` Supported timezone abbreviations: `CET` (+01:00), `CEST` (+02:00), `UTC` (+00:00), `GMT` (+00:00). Unrecognised abbreviations are left as-is and logged as warnings during generation. -### 5. Review topics +### Review topics -Each level's `topics:` list defaults to all adventure tags. Refine it to the subset of technologies actually used in that specific level. The challenges repo value wins on re-sync when set explicitly there; a manually refined value in `adventure.yaml` is only preserved when the challenges repo leaves `topics:` unset. See [What is preserved on re-sync](#what-is-preserved-on-re-sync) for the full rules. +Each level's `topics:` list defaults to all adventure tags. Refine it to the subset of technologies that are actually used in that level. This list is preserved on re-sync only if the challenges repo did not set it explicitly (see Re-syncing below). -### 6. Check architecture diagrams - -If the challenge author added an SVG to `docs/diagrams/` in the challenges repo, the sync fetches it automatically and no action is needed. If the sync log shows a warning that a diagram was not found, see [Architecture Diagrams](#architecture-diagrams) for the fallback steps. - -### 7. Update discussion_url +### Update discussion_url Once you have created the Discourse thread for a level, use the **Add Discussion URL to Level** workflow (Actions tab → Add Discussion URL to Level → Run workflow). @@ -331,123 +106,56 @@ Once you have created the Discourse thread for a level, use the **Add Discussion The workflow updates `discussion_url` in `adventure.yaml`, fetches the initial posts from Discourse, regenerates TypeScript, and opens a PR. Run it once per level. If the thread is brand-new and has no posts yet, the PR will contain an empty `discussionPosts` array; the hourly `refresh-community-data` workflow will populate it once posts appear. -`discussion_url` is a website-only field. It is never in the challenges repo and survives every re-sync automatically. +`discussion_url` in `adventure.yaml` is a website-only field. It is never in the challenges repo and survives every re-sync automatically. -### 8. Run the leaderboard script +### Add architecture diagrams (if needed) -```sh -node scripts/refresh-leaderboard.mjs -``` +If a level has an SVG architecture diagram, the sync strips the `architecture_diagram:` field because the SVG file must be added to `src/assets/diagrams/` manually. -Run this after `community_category_id` is set. It adds the adventure to the leaderboard data used on the site. See [Refresh Scripts](#refresh-scripts) for credential setup. +1. Add the SVG file to `src/assets/diagrams/<filename>.svg`. +2. Add `architecture_diagram: <filename>.svg` back to the level in `adventure.yaml`. -### 9. Verify devcontainer paths +Once set, `architecture_diagram` survives future re-syncs automatically. -`generate-adventures.mjs` cross-checks each level's `devcontainer:` value against the actual folder names in [`off-on-dev/open-source-challenges/.devcontainer`](https://github.com/off-on-dev/open-source-challenges/tree/main/.devcontainer) via `gh api`. - -**In generate mode** (the default, including the sync workflow): if a value is wrong but an unambiguous match can be found by slug and difficulty, the YAML is patched in place and a warning is printed: - -```text -Warning: <slug> levels[0]: devcontainer auto-corrected "<wrong>" → "<correct>" — update adventure.yaml in the challenges repo -``` - -If you see this warning, also fix the `devcontainer:` value upstream in the challenges repo so the next sync does not reintroduce the wrong value. - -**In `--validate-only` mode** (`npm run generate:validate`, used by CI): wrong values are always hard errors with no auto-correction. - -If `gh` is unavailable or unauthenticated, the check is skipped with a warning and generation proceeds. - -### 10. Update llms.txt - -`generate-adventures.mjs` patches `public/llms.txt` automatically, but the sync workflow does not commit that file. Run the generator locally and commit the result: +### Run the leaderboard script ```sh -npm run generate -git add public/llms.txt -git commit -s -m "chore: update llms.txt for <slug>" +node scripts/refresh-leaderboard.mjs ``` -Confirm the adventure appears under the Adventures section in `public/llms.txt` with the correct title and URL before pushing. +Run this after `community_category_id` is set. It adds the adventure to the leaderboard data used on the site. Requires `DISCOURSE_API_KEY` and `DISCOURSE_API_USERNAME` in your environment or a `.env` file. -`public/llms-full.txt` is not patched by the generator. Update it manually when adding a new adventure or level, or when a level's description changes significantly. +### Verify devcontainer paths -### 11. Run the a11y audit +Devcontainer path verification is handled automatically by the `sync-adventure` workflow during import. If a `devcontainer:` value in the YAML does not match a folder in the challenges repo's `.devcontainer` directory, the workflow logs a warning in its output. If you see such a warning, update the `devcontainer:` value in `adventure.yaml` to match the correct folder name, and also fix the value upstream in the challenges repo so the next sync does not reintroduce the wrong value. -After the build passes, run the accessibility audit against any new or changed pages. +### Verify llms.txt -- **With Claude Code:** use the `/a11y-audit` skill against any new adventure or level detail pages. -- **Without Claude Code:** the axe audit runs automatically as part of `npm run test:e2e` (step 12). Review the output for WCAG violations and resolve all failures before merging. +Add the new adventure entry to `public/llms.txt` by hand, following the format of the existing entries in the Adventures section. Add the adventure URL and a one-sentence description. Once levels are published, add per-level URLs as sub-bullets. -All severity-weighted findings must be resolved before merging. +### Run the a11y audit -### 12. Final checks +After the build passes, run the accessibility audit against any new or changed pages: ```sh -npm run lint && npm run lint:reuse && npm test && npm run build && npm run test:e2e -``` - -All checks must pass before merging. - ---- - -## Architecture Diagrams - -Each level can display an SVG diagram, an ASCII art fallback, and one or more prose paragraphs. All are rendered under the **Architecture** heading on the challenge page. - -| Field | Type | Renders as | -| --- | --- | --- | -| `architecture_diagram` | SVG filename | `<img>` (takes priority over `architecture_ascii`) | -| `diagram_alt` | string | Accessible alt text for the SVG. Required when `architecture_diagram` is set. | -| `architecture_ascii` | YAML literal block scalar | `<pre>` block, shown when no SVG is present | -| `architecture` | array of Markdown strings | Prose paragraphs always rendered below the diagram or ASCII block | - -### SVG in the challenges repo (normal path) - -Add the SVG to the challenges repo at: - -```text -adventures/<slug>/docs/diagrams/<slug>-<level>.svg +/a11y-audit ``` -Name it after the adventure slug and level: `dead-reckoning-intermediate.svg`, `lex-imperfecta-beginner.svg`. Then add the fields to the level YAML in the challenges repo: - -```yaml -architecture_diagram: "dead-reckoning-intermediate.svg" -diagram_alt: "One sentence describing what the diagram shows." -architecture: - - "Prose paragraph explaining the architecture." - - "Second paragraph if needed." -``` +Target any new adventure or level detail pages. All severity-weighted findings must be resolved before merging. -The sync auto-fetches the SVG from `docs/diagrams/` and writes it to `src/assets/diagrams/`. No action is needed on the website side. - -### SVG already in the website repo (fallback) - -If the SVG exists in `src/assets/diagrams/` on the website repo but not in the challenges repo (added manually before the auto-fetch path existed), the sync recognises it and re-adds `architecture_diagram` to the level automatically. Check that `architecture_diagram` and `diagram_alt` are set for that level in `adventure.yaml`: - -```yaml -architecture_diagram: "<slug>-<level>.svg" -diagram_alt: "One sentence describing what the diagram shows." -``` +### Final checks -### ASCII art fallback - -When no SVG is available, use `architecture_ascii` with a YAML block scalar to preserve whitespace: - -```yaml -architecture_ascii: | - ┌──────────┐ ┌──────────┐ ┌──────────┐ - │ Client │──────►│ API │──────►│ DB │ - └──────────┘ └──────────┘ └──────────┘ +```sh +npm run sync && npm run lint:reuse && npm run build && npm run test:e2e ``` -All architecture fields survive every re-sync once set. +All checks must pass before merging. --- ## Re-syncing an Open PR -If the challenges repo is updated while your PR is still open, or you want to promote a "Coming Soon" level to live, run the workflow again with the same (or updated) inputs. You do not need to close or recreate the PR. +If the challenges repo is updated while your PR is still open, or you want to promote a "Coming Soon" level to live, just run the workflow again with the same (or updated) inputs. You do not need to close or recreate the PR. ### What happens @@ -464,12 +172,10 @@ If the challenges repo is updated while your PR is still open, or you want to pr | --- | --- | --- | | `contributor:` (adventure) | Always | Survives every re-sync once set | | `community_category_id:` (adventure) | Always | Survives every re-sync once set; position is kept directly after `slug` | -| `meta_description:` (adventure) | Always | Survives every re-sync once set | | `month:` (adventure) | Always | Survives every re-sync once set | | `discussion_url:` / `community_url:` (level) | Always | Website-only fields; never in the challenges repo. Both field aliases are preserved independently | -| `architecture_diagram:` (level) | Always | Auto-fetched from `docs/diagrams/` when present in the challenges repo; otherwise recognised from `src/assets/diagrams/` if the file exists locally | -| `diagram_alt:` (level) | When upstream omits it | If the challenges repo sets `diagram_alt:` explicitly, the upstream value wins | -| `topics:` (level) | When upstream omits them | If the challenges repo sets `topics:` explicitly, the upstream value wins | +| `architecture_diagram:` (level) | Always | Stripped from incoming; preserved once added manually | +| `topics:` (level) | Only if challenges repo did not set them | If the challenges repo sets `topics:` explicitly, the upstream value wins | | `upcoming_levels:` entries for levels not yet upstream | Always | Placeholders for levels not yet authored in the challenges repo survive re-syncs so "Coming Soon" cards are not dropped | | All other level content | Never | Steps, objectives, toolbox, services, how_to_play, verification, etc. are always refreshed from the challenges repo | @@ -490,112 +196,41 @@ When a new level is ready in the challenges repo after the first adventure PR ha ## Adding a Solution Walkthrough -Solution walkthroughs live in `src/data/solutions/<adventure-id>/<level-id>.ts` and are committed to the repo. They are credited, gated behind the challenge deadline, and can be submitted as PRs at any point during a live challenge without spoiling anything for active participants. - -Before starting, make sure your local environment is set up: see [CONTRIBUTING.md](CONTRIBUTING.md) for Node version requirements and install steps. - -### What a good walkthrough looks like - -The `Solution` type gives you more than a wall of prose. Available block types: `text`, `code`, `image`, and `callout`. At the top level you can add a `context` section explaining the setup, per-step `takeaways` arrays, a `furtherReading` list, a `completeSolution` code card, and a closing `outro`. The annotated template at [`.ai/templates/solution/beginner.ts`](.ai/templates/solution/beginner.ts) shows every field with comments explaining what it does. - -The three Echoes Lost in Orbit solutions show the format in full: - -- [Beginner](https://offon.dev/adventures/echoes-lost-in-orbit/levels/beginner/solution/) -- [Intermediate](https://offon.dev/adventures/echoes-lost-in-orbit/levels/intermediate/solution/) -- [Expert](https://offon.dev/adventures/echoes-lost-in-orbit/levels/expert/solution/) +Solution walkthroughs live in `src/data/solutions/<adventure-id>/<level-id>.ts` and are committed to the repo. -### Use the `/add-solution` skill (Claude Code only) +### Use the `/add-solution` skill -This is a Claude Code slash command. If you are using a different AI tool, paste [`.claude/commands/add-solution.md`](.claude/commands/add-solution.md) as a system prompt or opening message and follow the same flow — the YAML frontmatter at the top is harmless and can be ignored. If you prefer to work without AI assistance, skip to [Without Claude Code](#without-claude-code) instead. +The fastest way to add a solution is with the Claude Code skill: ```sh /add-solution ``` -Paste or attach the walkthrough content in any format: markdown, YAML, HTML, or plain text. The skill infers the adventure ID, level ID, and contributor name from the content where possible, confirms them with you, and then: +Paste or attach the walkthrough content in any format — markdown, YAML, HTML, or plain text. The skill infers the adventure ID, level ID, and contributor name from the content where possible, confirms them with you, and then: 1. Parses the input into structured steps (`SolutionBlock[]` arrays with text, code, image, and callout blocks). 2. Downloads any referenced images and converts them to WebP at quality 85 using `cwebp`. Images are saved to `public/solutions/<adventure-id>/`. 3. Writes `src/data/solutions/<adventure-id>/<level-id>.ts` with the full typed `Solution` object. -4. Runs `npm run generate:solutions` to rebuild the solution index and patch region markers. -5. Runs `npm run build` and `npm run lint` to verify the output compiles. +4. Runs `npm run build` to verify the output compiles cleanly. +5. Run `/a11y-audit` against the new solution page to catch any accessibility issues before merging. -After the skill completes, run `/a11y-audit` against the new solution page before merging. +### How solutions are loaded -### Without Claude Code - -1. Find the adventure and level IDs from the challenge URL on offon.dev. For example, `offon.dev/adventures/echoes-lost-in-orbit/levels/beginner/` gives adventure ID `echoes-lost-in-orbit` and level ID `beginner`. Confirm the adventure ID matches a directory in `src/data/adventures/`. -2. Copy the template into `src/data/solutions/`: - - ```sh - mkdir -p src/data/solutions/<adventure-id> - cp .ai/templates/solution/beginner.ts src/data/solutions/<adventure-id>/<level-id>.ts - ``` - -3. Fill in the `Solution` object: update `adventureId`, `levelId`, `title`, `contributor`, and all content fields. Every field has an inline comment in the template you copied in step 2 explaining what it does. For the full type definitions see [`src/data/solutions/types.ts`](src/data/solutions/types.ts). -4. If the solution uses images, save them as WebP in `public/solutions/<adventure-id>/` and reference them with absolute paths in the `image` blocks (e.g. `/solutions/<adventure-id>/<level-id>-step-name.webp`). - -5. Run `npm run generate:solutions` to rebuild the solution index and patch region markers. This step is not optional: without it your solution has no route and your PR fails CI. -6. Start the dev server (`npm run dev`) and preview your solution at `http://localhost:8080/adventures/<adventure-id>/levels/<level-id>/solution/`. -7. Run the mandatory checks — all four must pass with zero failures: - - ```sh - npm run lint - npm run lint:reuse - npm test - npm run build && npm run test:e2e - ``` - - `npm run test:e2e` includes the axe accessibility audit. Review the output for WCAG violations before opening a PR. - -8. Open a PR against `main` on the upstream repo. - -### Attribution - -The `contributor` field on `Solution` takes `{ name: string; url?: string }`. If the person is already listed in `src/data/adventures/contributors.ts`, import them and pick `name` and `url` rather than duplicating the values: - -```ts -import { KATHARINA_SICK } from "@/data/adventures/contributors"; - -contributor: { name: KATHARINA_SICK.name, url: KATHARINA_SICK.url }, -``` - -If the contributor is not yet in `contributors.ts`, use the inline form directly — do not add a new entry to `contributors.ts` just for a solution. That file is maintained by the core team and entries are added when a contributor becomes a recurring presence. - -Licensing is handled centrally through `REUSE.toml`. You do not need to add SPDX headers to solution files. Code is MIT and written content is CC BY 4.0. +There is no solutions generator. `src/lib/solutions.ts` loads every `src/data/solutions/<adventure-id>/<level-id>.ts` via `import.meta.glob` at build time, and the solution route (`/adventures/<id>/levels/<level>/solution/`) is generated by `getStaticPaths()`. Just add the `.ts` file — no barrel or region markers to update. Add the route to the test lists in `e2e/smoke.spec.ts` and `e2e/a11y.spec.ts`. ### Deadline gating Solutions are not visible on the site until the challenge deadline has passed. The solution page checks `level.deadline` (falling back to `adventure.rewards.deadline`) and renders a locked state with the deadline date until that moment arrives. Once the deadline passes, the page shows the full walkthrough automatically with no code change needed. -This means you can open a solution PR at any point during a live challenge without spoiling anything for active participants. +This means you can add a solution file to the repo at any point during the challenge period and it will not spoil anything for active participants. ### Output location ```text src/data/solutions/<adventure-id>/<level-id>.ts ← authored TypeScript (commit this) public/solutions/<adventure-id>/<level-id>-*.webp ← converted images (commit these) -src/data/solutions/index.ts ← auto-generated index file (commit this) -src/data/solutions/manifest.ts ← auto-generated manifest (commit this) ``` -### What the generator updates - -`scripts/generate-solutions.mjs` scans every `.ts` file in `src/data/solutions/<adventure-id>/` (excluding `index.ts`, `manifest.ts`, and `types.ts`) and rebuilds six files automatically: - -| File | What gets patched | -| --- | --- | -| `src/data/solutions/index.ts` | Fully regenerated: one import per solution file, exported as `SOLUTIONS: Solution[]`. Never edit by hand. | -| `src/data/solutions/manifest.ts` | Lightweight set of solution IDs regenerated on every run. Used to check solution availability without importing full solution data. Never edit by hand. | -| `react-router.config.ts` | `GENERATED:solutions` region: one prerender entry per solution route (`/adventures/<id>/levels/<level>/solution`). | -| `src/test/seo.test.ts` | `GENERATED:solutions` region: one route entry per solution. | -| `src/test/prerender.test.ts` | `GENERATED:solutions` region: one `{ file, check }` entry per solution asserting the built HTML contains `"Solution"`. | -| `e2e/smoke.spec.ts` | `GENERATED:solutions` region: one `{ path, title }` smoke-test entry per solution. | - -You do not need to touch any of these files manually when adding a solution. - -Run `npm run generate:solutions:validate` to check that the generator output is in sync without writing any files. CI runs this check automatically on PRs that touch `src/data/solutions/**`. If `index.ts`, `manifest.ts`, or any `GENERATED:solutions` region is out of sync, the script prints `Out of sync: <filepath>` per file and exits non-zero. - --- ## Workflows at a Glance @@ -604,7 +239,7 @@ Run `npm run generate:solutions:validate` to check that the generator output is | --- | --- | --- | | `sync-adventure.yml` | Manual (`workflow_dispatch`) | Sync adventure content from the challenges repo and open or update a PR | | `add-discussion-url.yml` | Manual (`workflow_dispatch`) | Set a Discourse thread URL for a level after it has been merged, and open a PR with updated YAML and initial posts | -| `validate-adventures.yml` | PR (when adventure, solution, sitemap, or config files change) | Validate YAML schema; check adventure and solution generated files are up-to-date; verify route, sitemap, and prerender consistency; check all adventures are registered in leaderboard data | +| `validate-adventures.yml` | PR (when adventure files change) | Validate adventure YAML against the Zod content schema (`astro sync`), check per-level discussion JSON exists, verify `ADVENTURE_CATEGORIES` registration | | `deploy.yml` | Push to `main` | Build and deploy to GitHub Pages at [offon.dev](https://offon.dev) | | `preview.yml` | Open PR | Deploy a PR preview at `/pr-preview/pr-<n>/` | | `refresh-community-data.yml` | Hourly + manual | Refresh discussion posts, leaderboard data, and community leaders from Discourse | @@ -633,4 +268,4 @@ DISCOURSE_API_USERNAME=your_username The `.env` file is gitignored. For CI, set `DISCOURSE_API_KEY` and `DISCOURSE_API_USERNAME` as repository secrets in **Settings > Secrets and variables > Actions**. -> The `COMMUNITY_BASE` constant in each of these scripts is a necessary duplicate of `COMMUNITY_URL` in `src/data/constants.ts`. The scripts run in Node outside the Vite build and cannot import from `src/`. Always update all six places together if the community URL ever changes: `refresh-discussions.mjs`, `refresh-leaderboard.mjs`, `refresh-community-leaders.mjs`, `generate-community-sitemap.mjs`, `set-discussion-url.mjs` (called by the `add-discussion-url.yml` workflow, not run directly), and `src/data/constants.ts`. +> The `COMMUNITY_BASE` constant in each refresh script is a necessary duplicate of `COMMUNITY_URL` in `src/lib/site.ts`. The scripts run in Node outside the Vite build and cannot import from `src/`. Always update all five places together if the community URL ever changes: `refresh-discussions.mjs`, `refresh-leaderboard.mjs`, `refresh-community-leaders.mjs`, `generate-community-sitemap.mjs`, and `src/lib/site.ts`. diff --git a/AGENTS.md b/AGENTS.md index db0a5be26..f9771228c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,40 +24,43 @@ Workflow-specific AI prompts live in [`.claude/commands/`](.claude/commands/). A A `spec-first-coding` prompt is available for Claude Code users (installed globally at `~/.claude/skills/`). It enforces W3C spec citations before generating any accessibility-related code. For other AI tools, cite the relevant W3C spec manually before implementing any accessibility feature. -Use the `a11y-audit` prompt for all accessibility audits in this repo. The four sub-prompts can also be used independently when working in their specific domain. +Use the `a11y-audit` prompt for all accessibility audits in this repo. --- ## Icons -- Always use **lucide-react** for all icons. Do not add any other icon library. -- Decorative icons next to visible text: `aria-hidden="true"`, no `aria-label`. -- Icon-only interactive elements: add `aria-label` to the parent element, do not use `aria-hidden`. -- When placing an icon next to text in a link or button, always add `inline-flex items-center gap-1` to the container. A lone icon inside a plain `inline` element drops below the text baseline. +- Icons come from `unplugin-icons` using the `lucide` set (`@iconify-json/lucide`). Import with `~icons/lucide/<name>` in both `.astro` and `.vue` files. +- Do not install any other icon library. +- Decorative icons next to visible text: set `aria-hidden="true"` on the icon element, no `aria-label`. +- Icon-only interactive elements: add `aria-label` to the parent `<button>` or `<a>`, not on the icon itself. +- When placing an icon next to text in a link or button, add `inline-flex items-center gap-1` to the container. - See the Icons section of `styleguide.md` for the full icon map, size conventions, and current usage. -- **Brand/social icon exception:** Official brand SVGs (e.g. the LinkedIn "in" mark) are exempt from the lucide-react-only rule when no equivalent exists in lucide-react. Place the SVG inline, set `aria-hidden="true"` on the `<svg>` element, and put `aria-label` on the parent interactive element. Use `fill="currentColor"` so hover and theme color changes apply. Document every brand SVG addition in the Icon map table in `styleguide.md`. Current exceptions: LinkedIn icon in `Footer.tsx`. --- ## Project Overview -**offon.dev** is the main website for OffOn, a platform for open source enthusiasts. -It is fully static with no backend and no database. Pages are prerendered at build time using React Router v8 framework mode (`ssr: false`). +**offon.dev** is the main website for OffOn, a platform for open source enthusiasts. It is fully static with no backend and no database. Pages are prerendered at build time by **Astro** (`output: 'static'`); interactivity is added as `.astro` components with vanilla `<script>` blocks. -Community activity happens on a separate Discourse instance. Its display name is **community.offon.dev**, but the real URL is managed via the `COMMUNITY_URL` constant in `src/data/constants.ts`. Do not hardcode it. Do not attempt to replicate or integrate Discourse functionality here. +> This project was migrated from React Router v8 to Astro. If you find a reference to `root.tsx`, `entry.server`, `routes.ts`, `react-router.config.ts`, `*.generated.ts`, `useConsent`, `useTheme`, `FilteredLevelCard.tsx`, `dist/client/`, or `scripts/generate-adventures.mjs`, it is stale and no longer exists. + +Community activity happens on a separate Discourse instance. Its display name is **community.offon.dev**. Use the `COMMUNITY_URL` constant from `src/lib/site.ts`. Never hardcode it. Do not replicate or integrate Discourse functionality here. --- ## Stack -- **Framework:** React 19 with TypeScript, bundled via Vite. Check `package.json` for current versions. -- **Styling:** Tailwind CSS 4, configured CSS-first via `src/index.css` (`@theme` block). There is no `tailwind.config.ts`; it was deleted as part of the Tailwind 4 migration. -- **Components:** Minimal shadcn/ui surface. `src/components/ui/` contains only `badge.tsx` and `tooltip.tsx`. Most Radix UI packages were intentionally removed. -- **Routing:** React Router v8 framework mode (static prerendering with `ssr: false`) -- **Testing:** Vitest + @testing-library/react (unit/component); Playwright (smoke tests in `e2e/`) -- **Hosting:** GitHub Pages -- **PR previews:** pr-preview-action -- **Node.js:** 26 is required. Version is pinned in `.nvmrc`. Run `nvm use` to switch automatically. +- **Framework:** Astro 7 (static output), TypeScript. Check `package.json` for versions. +- **Interactivity:** `.astro` components with vanilla `<script>` blocks. `@astrojs/vue` is installed but the site currently ships **zero Vue islands** — it is retained so the first island is a one-file change. Only reach for a Vue island when the component has genuinely reactive state that a class toggle and a plain script cannot express. +- **Styling:** Tailwind CSS 4, CSS-first via `src/styles/index.css` (`@theme` block) and the `@tailwindcss/vite` plugin. No `tailwind.config.ts`. +- **Icons:** `unplugin-icons` (lucide set via `@iconify-json/lucide`) in both `.astro` and `.vue` files. +- **State:** nanostores in `src/stores/`. Read directly via `.subscribe()`/`.get()` in inline scripts. `@nanostores/vue` is not installed; install it when the first Vue island that needs shared state is added. +- **Content:** Astro Content Collections (Zod-validated) over authored YAML. See "Content collection" below. +- **Routing:** Astro file-based routing + `getStaticPaths()`. Trailing slashes always. +- **Testing:** Playwright + `@axe-core/playwright` in `e2e/` (a11y + SEO/smoke/hydration/consent). +- **Hosting:** GitHub Pages. PR previews: `rossjrw/pr-preview-action`. +- **Node.js:** 26 (pinned in `.nvmrc`; `nvm use`). --- @@ -67,28 +70,27 @@ Community activity happens on a separate Discourse instance. Its display name is | Thing | Convention | Example | | --- | --- | --- | -| Component files and exports | PascalCase | `FilteredLevelCard.tsx`, `export const FilteredLevelCard` | -| Hook files and exports | camelCase, `use` prefix | `useTheme.tsx`, `export function useTheme` | -| Module-level constants from static data | SCREAMING_SNAKE_CASE | `ADVENTURES`, `ALL_TAGS` | -| Route segments | kebab-case | `community-guide`, `adventure-detail` | +| Astro components / pages | PascalCase files (components), kebab or `[param]` (pages) | `AdventureCard.astro`, `adventures/[id].astro` | +| Vue island components | PascalCase | `MyFeature.vue` (no islands exist yet; convention is ready) | +| nanostores | camelCase file, `$`-prefixed export | `stores/consent.ts` → `$consent` | +| Module-level constants | SCREAMING_SNAKE_CASE | `BRAND_NAME`, `DIFFICULTIES` | +| Route segments | kebab-case | `presentation-templates`, `handbook` | ### What lives where -- Logic derived from `ADVENTURES` belongs in `src/data/adventures/index.ts`, exported, and imported everywhere. Do not re-derive it in component files. -- Reusable card or list markup belongs in `src/components/`, not duplicated inline. Extract before the second copy appears. -- Redirect routes that share a destination share a single file in `src/pages/redirects/`. The filename describes the destination, not the source (e.g. `HandbookRedirect.tsx`). +- Adventure data is derived from the `adventures` content collection (`getCollection('adventures')`). Shared derivations go in `src/lib/` (e.g. `challenges.ts`, `adventure-derive.mjs`). Do not re-derive ad hoc in pages. +- Reusable markup belongs in `src/components/` (`.astro` for static, `.vue` for islands). Extract before the second copy appears. +- Retired URLs are handled by the `redirects` map in `astro.config.mjs`, not by page files. --- ## URLs and External Organisations -- The canonical domain for this site is <https://offon.dev>. -- og:url, og:image, and all absolute URLs must use <https://offon.dev>. -- The og:image file is public/og.png and its full URL is <https://offon.dev/og.png>. Its dimensions are 1200 x 630 px. -- PR preview deployments are served from the gh-pages branch under /pr-preview/pr-{number}/. -- The open source challenges content lives in a separate organisation at <https://github.com/off-on-dev/open-source-challenges>. This is an intentional external link and must never be changed or flagged as a violation. -- The community Discourse instance is at <https://community.offon.dev>. Use the `COMMUNITY_URL` constant from `src/data/constants.ts`, never hardcode this URL. -- `COMMUNITY_DISPLAY_NAME` is defined in `src/data/constants.ts` as the user-facing display name for the community URL. Use it for visible text, use `COMMUNITY_URL` for href attributes. +- The canonical domain is <https://offon.dev>. `og:url`, `og:image`, and all absolute URLs must use it. +- The `og:image` is `public/og.png` (<https://offon.dev/og.png>), 1200 x 630 px. +- PR previews are served from the gh-pages branch under `/pr-preview/pr-{number}/`. +- The open source challenges content lives at <https://github.com/off-on-dev/open-source-challenges> (intentional external link). +- The community Discourse instance is <https://community.offon.dev>. Use `COMMUNITY_URL` from `src/lib/site.ts`, never hardcode. Use `COMMUNITY_DISPLAY_NAME` for visible text. --- @@ -96,45 +98,33 @@ Community activity happens on a separate Discourse instance. Its display name is ```text src/ - components/ # Reusable UI components (named exports, PascalCase files) - pages/ # Route-level page components - data/ # Static data files (TypeScript objects/arrays) - hooks/ # Custom React hooks - lib/ # Shared utilities - assets/ # Static assets bundled by Vite - Layout.tsx # App shell: providers, skip nav, scroll-to-top, consent banner, and Outlet + pages/ # File-based routes (.astro). Dynamic routes use getStaticPaths(). + index.astro # Home + adventures/[id].astro, adventures/[id]/levels/[levelId].astro (+/solution.astro) + challenges/[...tag].astro, 404.astro, and the static pages + _app.ts # Vue appEntrypoint (island-wide setup) + layouts/ + Layout.astro # App shell: <head> (SEO, CSP, favicons, theme + GA4 bootstrap, JSON-LD), + # ClientRouter, skip-nav, Navbar, <slot/>, Footer, ConsentBanner + components/ # *.astro (static, zero-JS) and *.vue (reserved for islands) + content.config.ts # Content collection: Zod schema + custom loader + markdown rendering + data/ + adventures/<id>/adventure.yaml + <level>-posts.json + leaderboard.json + adventures/contributors.ts, types.ts + solutions/<id>/<level>.ts (pre-built Solution objects), sponsors.ts, team.ts + lib/ # markdown-pipeline.mjs, adventure-derive.mjs, community-data.ts, + # solutions.ts, challenges.ts, difficulty.ts, markdown.ts, utils.ts, + # site.ts (constants), level-constants.mjs, deadline.mjs + stores/ # nanostores: consent.ts ($consent + gtag injector) + styles/index.css # Tailwind @theme, component classes, light-mode overrides + assets/diagrams/ # Architecture SVGs (imported per-level via import.meta.glob) e2e/ - smoke.spec.ts # Playwright smoke tests - a11y.spec.ts # Axe-core accessibility audit (dark and light mode) - hydration.spec.ts # React hydration checks - visual.spec.ts # Visual regression tests (local only, not run in CI) - wsg.spec.ts # Well-known/agent-skills verification -public/ - fonts/ # Self-hosted fonts (Inter, Syne, JetBrains Mono) - brand/ # OffOn brand assets (SVG + PNG logos, Nyx illustrations). Referenced by deck/index.html and BrandGuidelines.tsx. - team/ # Board member photos (*.webp). Used by BoardSection and deck/index.html host slides. Do not duplicate into src/assets/. - speakers/ # Event speaker photos (*.webp). Used by presentation decks only. Speakers are per-event and distinct from board members. - solutions/ # Solution walkthrough screenshots, one subdirectory per adventure ID (e.g. solutions/echoes-lost-in-orbit/). Referenced by src/data/solutions/ with absolute paths. - reveal/ # Self-hosted Reveal.js 6.0.1 library. Used by deck/index.html, deck-template/index.html, and all generated Reveal.js decks. - deck/ # Reveal.js presentation for Open Source Talks events (public/deck/index.html). Served at /deck/. All asset paths use ../ to resolve sibling directories correctly regardless of trailing-slash normalization. - deck-template/ # Boilerplate template for the create-presentation prompt (Reveal.js format). Edit deck-template/index.html to update the design system for all future decks. Asset paths use ../ so the file works both from the dev server (/deck-template/) and inside the standalone ZIP. - nyx.webp # Nyx mascot illustration. Referenced in BottomCTA and About via import.meta.env.BASE_URL. - nyx_peek.webp # Nyx peek variant. Referenced in About via import.meta.env.BASE_URL. -.ai/ - prompts/ # Vendor-neutral AI prompts for contributor workflows - templates/ # Reusable templates (solution starter, presentation generators) -.github/ - workflows/ - 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 - refresh-community-sitemap.yml # Daily community sitemap regeneration - sync-adventure.yml # workflow_dispatch: sync an adventure from the challenges repo - validate-adventures.yml # PR check: validates adventure YAML, routes, and sitemap consistency - validate-docs.yml # PR check: ensures styleguide.md/README.md updated with code changes - add-discussion-url.yml # workflow_dispatch: set discussionUrl for a level and fetch initial posts - a11y-scan.yml # Scheduled weekly accessibility scan (Monday 08:00 UTC) - reuse.yml # REUSE licence compliance check on push and PR + a11y.spec.ts # axe (dark/light/forced-colors) + touch targets + focus rings + zoom + smoke.spec.ts # per-route title/canonical/OG/h1 + island hydration +public/ # copied verbatim to dist/ (fonts, favicons, brand, well-known, decks, etc.) +astro.config.mjs, tsconfig.json, playwright.config.ts, package.json +.github/workflows/ # deploy, preview, validate-adventures, sync-adventure, + # add-discussion-url, refresh-community-*, a11y-scan, reuse ``` --- @@ -142,415 +132,192 @@ public/ ## Commands ```sh -nvm use # Switch to Node 26 (required) -npm run dev # Start local dev server (http://localhost:8080) -npm run build # Production SSG build (React Router v8) -> dist/client/ -npm run build:dev # Dev-mode build -npm run lint # ESLint +nvm use # Node 26 +npm run dev # Astro dev server (http://localhost:4321) +npm run build # Static build -> dist/ +npm run preview # Serve the built dist/ (astro preview) +npm run sync # astro sync — runs the Zod content schema; fails on invalid adventure YAML +npm run test:unit # Vitest unit tests (lib, stores) — fast, no server needed +npm run test:e2e # Playwright (a11y + smoke). Requires `npm run build` first; `astro preview` serves the built dist/ +npm run lint # ESLint (astro/vue/ts) npm run lint:reuse # REUSE licence compliance (requires: pip install reuse) -npm test # Run tests once (Vitest) -npm run test:watch # Tests in watch mode -npm run test:coverage # Run tests with v8 coverage (uses @vitest/coverage-v8) -npm run test:e2e # Playwright smoke, a11y, hydration, and wsg tests (requires npm run build first) -npm run test:visual # Visual regression tests (requires npm run build first) -npm run test:visual:update # Update visual baseline screenshots -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 generate:solutions # Regenerate solution barrel index from src/data/solutions/ -npm run generate:solutions:validate # Validate solution files without writing the barrel index - -npx shadcn@latest add <component> # Add a shadcn/ui component +rm -rf .astro # Bust the content collection pipeline cache (after editing markdown-pipeline.mjs or adventure-derive.mjs) # Regenerate downloadable presentation ZIPs and PPTX (run from repo root) node .ai/templates/generate-reveal-zip.mjs # -> public/downloads/offon-reveal-template.zip +# pptxgenjs is not in devDependencies. Install it locally first: npm install pptxgenjs node .ai/templates/generate-pptx.mjs # -> public/downloads/offon-deck-template.pptx ``` +There is **no** content generator, `npm run generate`, or `*.generated.ts`. Routes and rendered prose come from the content collection at build time. + --- ## Code Quality -- Use explicit return types on all functions and components. -- Prefer named exports for components. -- Keep components small and single-responsibility. -- Functions must have a single responsibility. If a function requires more than one level of conditional nesting to describe in plain language, split it. -- Use functional components with hooks only. No class components. -- Prefer `const` over `let`, never `var`. -- Use async/await over promise chains. Always handle errors explicitly. -- Never leave unused imports, variables, or dead code. -- Write self-documenting code. Add comments only for non-obvious logic. +- Explicit return types on functions and helpers. +- Keep components small and single-responsibility. Split a function that needs more than one level of conditional nesting. +- Prefer `const`; never `var`. Use async/await; handle errors explicitly. +- Never leave unused imports, variables, or dead code. Self-documenting code; comment only non-obvious logic. --- ## Stability Rules - Never remove or rename existing exports without checking all usages first. -- Never change a component's props interface without updating all call sites. +- Never change a component's props without updating all call sites. - Never delete files without confirming they are unused. - When refactoring, change one thing at a time. Do not mix refactors with feature changes. -- Always verify no TypeScript errors after making changes. -- Prefer extending existing components over rewriting them. -- If a change could break something, flag it explicitly before proceeding. +- Always verify `npm run build` has no TypeScript errors after changes. +- Prefer extending existing components over rewriting them. Flag risky changes before proceeding. --- ## Debugging Rules -When diagnosing a bug, especially in the production build, follow these rules without exception. They exist to prevent debugging by accumulation. - ### Evidence rules -- Never claim a fix worked based on source inspection alone. The only signal that counts is the expected behavior observed in a real browser against the current bundle hash. -- Before acting on any error message, verify the error came from the current build. Compare the bundle hash in the error stack trace (e.g. `index-XXXX.js`) against the latest build output. If they differ, the browser is serving cached code and the error is stale. -- Before acting on any diagnostic output, state what evidence supports the conclusion. "Only X was left in the DOM" is not evidence of what the DOM looked like at error time. React's error recovery can tear down the tree before the diagnostic runs. -- When a grep claims to confirm something, verify the grep pattern is specific enough to exclude false positives. Strings like "hydrateRoot" exist in production React too, so their presence proves nothing about whether the build is minified. +- Never claim a fix worked from source inspection alone. The only signal that counts is the expected behaviour observed in a real browser against the current build (`npm run build && npm run preview`). +- Before acting on any error, verify it came from the current build. Astro emits hashed asset names (`_astro/*.js`); a stale hash means the browser is serving cached code. +- Before acting on diagnostic output, state what evidence supports the conclusion. ### One-fix-at-a-time rule -- Never stack fixes. One change, rebuild, verify in a real browser, then the next. If you apply two fixes before verifying, you cannot tell which one worked or if either did. -- Commit after every verified fix. Each commit should have a clear before/after. -- If the same bug has been "fixed" more than once in a session and still reproduces, stop. The diagnosis is wrong. Go back to first principles. +- Never stack fixes. One change, rebuild, verify, then the next. Commit after every verified fix. +- If the same bug has been "fixed" more than once in a session and still reproduces, stop and go back to first principles. -### Build cache rules +### Server / cache rules -- Always run `rm -rf dist node_modules/.vite` before any rebuild you intend to verify against. Vite's cache can silently produce stale output. -- After rebuilding, always compare the new bundle hash to the previous one. If the hash is identical, the cache was reused. Clear it and rebuild. - -### Getting unminified React errors - -- The `--mode development` flag alone does not produce a dev React build with Vite's React plugin. Proof: a dev React bundle is roughly 1.4 MB; a production bundle is roughly 330 KB. -- To force a dev React build, add to vite.config.ts inside defineConfig: - define: { 'process.env.NODE_ENV': JSON.stringify('development') }, - build: { minify: false, sourcemap: true } -- Verify the dev build actually happened: `ls -lh dist/assets/index-*.js`. Size should be ~1.4 MB, not ~330 KB. -- Revert this change before merging to main. +- Kill any stray `astro dev`/`astro preview` on port 4321 before running tests. +- If a build looks stale, `rm -rf dist .astro` and rebuild. --- ## TypeScript -- `noImplicitAny: false` and `strictNullChecks: false` are intentional. Do not change them. -- Avoid `any` in new code. Use proper types or `unknown` with narrowing. -- Never use `@ts-ignore`. -- Use `@/*` path alias for all imports from `src/`: e.g. `import { cn } from "@/lib/utils"`. -- Prefer `type` over `interface` for object shapes. +- Use the `@/*` path alias for imports from `src/`: `import { BRAND_NAME } from "@/lib/site"`. +- Astro components declare props with `interface Props { ... }` and `Astro.props`. In plain `.ts` prefer `type` for object shapes. +- Avoid `any`; use `unknown` with narrowing. Never `@ts-ignore`. `tsconfig.json` extends `astro/tsconfigs/strict`. --- ## Components -- Always check `src/components/ui/` before building a new primitive. -- `src/components/ui/` contains two files: `badge.tsx` and `tooltip.tsx`. Adding a new shadcn component requires an immediate use case in the same PR. Unused components are removed. To add one: `npx shadcn@latest add <component>`. -- Never modify files inside `src/components/ui/` directly. Extend or wrap them in `src/components/`. -- Page-level components go in `src/pages/`. Reusable components go in `src/components/`. -- Extract sub-components into `src/components/` rather than nesting them inline. -- Do not duplicate card or list markup across components. If the same JSX structure appears in two places, extract a shared component. `FilteredLevelCard` is the established pattern. -- **Buttons:** use raw `<button>` elements with the CSS utility classes defined in `src/index.css` (`.btn-primary`, `.btn-ghost`, `.btn-soft`, `.btn-inverse`, `.btn-ghost-inverse`). There is no `Button` component wrapper and no `@radix-ui/react-slot` dependency. See `styleguide.md` for which class to use on which background color. -- **Toasts:** if toast notifications are ever needed, install `sonner` and add `src/components/ui/sonner.tsx` (shadcn pattern). Mount `<Toaster>` in the nearest layout that actually triggers a toast. Do not install speculatively. -- **TooltipProvider** is intentionally not mounted in `Layout.tsx` until a call site exists. Wrap only the subtree that uses `<Tooltip>` with `<TooltipProvider>` at that point. -- **Author-controlled prose fields contain pre-rendered HTML.** Every YAML/TS field that holds prose written by a challenge author (`level.audience`, `tool.description`, `step.title`, `step.content`, `contributor.about`, `rewards.eligibility`, `tier.description`, `rewards.ranking_note`, `level.learnings`, `level.objective`, `level.intro`, `level.backstory`, `level.hook`, `level.scenario`, `level.architecture`, `adventure.story`, `adventure.backstory`) is converted from Markdown to sanitised HTML at build time by `scripts/generate-adventures.mjs`. Always render them with `dangerouslySetInnerHTML={{ __html: value }}` and the `md-inline` (inline prose) or `md-content` (block content) CSS class. Never render as `{value}` directly. Identifier fields (`id`, URLs, enum values like `difficulty`, emoji) are not author prose and are rendered directly. - - **When the container is an interactive element** (e.g. a `<Link>` card or a `<button>`), call `stripLinks(html)` from `src/lib/markdown.ts` before passing to `dangerouslySetInnerHTML` to prevent nested `<a>` inside `<a>` or `<button>`, which is invalid HTML. - - **When placing a prose HTML field in a plain-text context** (e.g. a `<meta content="">` attribute), call `stripHtml(html)` from `src/lib/markdown.ts`. This strips tags *and* decodes HTML entities. Using a bare tag-strip regex leaves entities intact; React then double-encodes them in the attribute value (e.g. `&` -> `&amp;`). - - **Exception: `adventure.story` in `AdventureCard` and `summaries.ts`:** The summary card and `ADVENTURE_SUMMARIES` store `story` as plain text (no HTML) so the home page renders it as a plain `<span>` with no markdown overhead. The generator emits a build-time warning if any story value contains markdown syntax (`*`, `_`, `` ` ``). Keep story field values as plain prose. - - **The markdown pipeline (`unified`, `remark-parse`, `remark-gfm`, `remark-rehype`, `rehype-raw`, `rehype-sanitize`, `rehype-stringify`) is dev-only**, used only by `scripts/generate-adventures.mjs`. Do not import any of these packages in component or page files. +- Static UI is a `.astro` component (zero JS shipped). For interactivity, default to a `.astro` component with a plain `<script>`. The site currently ships **zero islands**. +- Only reach for a **Vue island** when the component has genuinely reactive state that a class toggle and a small script cannot express. Hydrate with the lightest directive that works: `client:visible` / `client:idle` by default, `client:load` only for above-the-fold interactivity. +- **Frameworks: Vue, never React.** `@astrojs/vue` stays installed even while unused. Do not strip it. +- **Inline links in prose need `{" "}` around them.** Astro removes whitespace between text and an adjacent element when the source has a newline there. +- `.astro` components cannot be rendered inside a `.vue` island. If an island needs a badge/pill/icon, inline the markup. +- **Buttons:** raw `<button>` with the CSS utility classes in `src/styles/index.css` (`.btn-primary`, `.btn-ghost`, `.btn-soft`, `.btn-inverse`, `.btn-ghost-inverse`). No Button wrapper. See `styleguide.md`. +- **Touch targets (WCAG 2.5.8):** nav/footer links and any blockified interactive element must be at least 24x24 px. Nav links use `min-h-[44px]`, footer links `min-h-[48px]`. +- **Author prose is pre-rendered HTML.** Render with `set:html={value}` and the `md-inline` or `md-content` class, or via `<InlineProse html={...} />`. Never render `{value}` raw. + - Inside an interactive element: call `stripLinks(html)` from `@/lib/markdown` first. + - In a plain-text context (e.g. a meta attribute): call `stripHtml(html)` from `@/lib/markdown`. + +### Listener lifecycle (mandatory) + +Any event listener or store subscription registered in a component `<script>` must be registered under `astro:page-load` and torn down under `astro:before-swap`. `MobileMenu.astro` is the canonical reference. Astro's ClientRouter replaces `<body>` on every client-side navigation; ES module scripts do not re-run; listeners on old DOM nodes are dead. + +```ts +let teardown: (() => void) | null = null; + +function initMyComponent(): void { + const el = document.querySelector("[data-my-component]"); + if (!el) return; + const onEvent = () => { /* ... */ }; + el.addEventListener("click", onEvent); + teardown = () => el.removeEventListener("click", onEvent); +} + +document.addEventListener("astro:page-load", initMyComponent); +document.addEventListener("astro:before-swap", () => { teardown?.(); teardown = null; }); +``` ### Component CSS patterns -- `hero-badge` class on the hero pill `<div>` in `Hero.tsx`. It is used for CSS scoping of light mode overrides. -- `logo-link` class on the Navbar logo `<Link>`. It is used to exclude the logo from nav link hover styles. -- Footer nav group labels ("explore", "community") use `<p>` with `font-sans font-normal text-xs uppercase tracking-widest text-faint`. Do not use heading elements (`<h2>` etc.) here. The nav groups are already identified by `aria-label`, and heading elements create spurious document-outline entries that disrupt screen reader H-key navigation. Source text must be lowercase because these are overline labels styled with `text-transform: uppercase`. -- `data-difficulty` attribute on `DifficultyBadge`. It is used for CSS targeting of badge text color. -- `contributor-pill` class on `ContributorBadge`. Scopes light mode overrides: transparent background with slate border instead of the near-invisible `bg-primary/5`. -- `contributor-pill-glow` class on `ContributorBadge` (applied via `glow` prop). Static amber box-shadow glow, sized for a small pill. Used only on `ChallengeDetail` -- not in `AdventureCard`. -- `docs-ext-link` class on all inline prose links site-wide. Bundles `inline-flex`, `align-items: center`, `gap`, `underline`, `decoration-thickness`, `underline-offset`, `border-radius`, focus-visible ring, and color/hover transitions. Handles both modes: dark mode foreground text with amber underline, hover to full `#ffc034`; light mode near-black text with `currentColor` underline, hover to `--link-hover-light` (`hsl(41 100% 22%)` dark amber, ~7.4:1 contrast). Used in `CommunityGuide`, `DiscussionSection`, `CommunitySection`, `LevelCard`, `PersonNameLink`, `ChallengeBuildersSection`, `ChallengeDetail`, `CommunitySidebar`, `RewardsCard`, `Accessibility`, and `Privacy`. Links inside pre-rendered adventure HTML use the `.md-inline a` and `.md-content a` rules in `src/index.css` instead. Do not use `hover:text-primary` or `hover:underline` on inline links, and do not add redundant `inline-flex items-center gap-*` utilities. Use `docs-ext-link` alone, adding only contextual utilities (font-size, weight, margin). +- `hero-badge` on the Hero pill; `logo-link` on the Navbar logo; `data-difficulty` on `DifficultyBadge`; `contributor-pill` / `contributor-pill-glow` on `ContributorBadge`. +- Footer nav group labels use `<p class="font-sans ... text-faint">`, not headings. +- `docs-ext-link` on all inline prose links site-wide. Do not add redundant `hover:*`/`inline-flex` utilities. --- -## Data +## Content Collection -- 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`, `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`. +Authored as YAML at `src/data/adventures/<id>/adventure.yaml`, loaded and validated by `src/content.config.ts`: + +- **Custom loader** (not `glob()`): reads YAML with the `yaml` package (YAML 1.2 core). Astro's built-in glob YAML parser auto-casts unquoted ISO timestamps to `Date` objects, corrupting `deadline` fields. +- **Zod schema** mirrors the old JSON Schema (`.strict()` = fail on unknown fields). `npm run sync` runs it; invalid YAML fails the build. +- **Markdown fields** are rendered to sanitised HTML in the loader via `mdToInline`/`mdToBlock` from `src/lib/markdown-pipeline.mjs`. `astro:content` returns `entry.data` with HTML fields already rendered. +- **Field normalization** lives in `src/content.config.ts` + `src/lib/adventure-derive.mjs`. +- **Discussion + leaderboard** JSON is read at build time by `src/lib/community-data.ts`. No client fetch. +- **Solutions** are pre-built TS objects in `src/data/solutions/<id>/<level>.ts`, loaded via `import.meta.glob`. No generation step. +- **No runtime `fetch` in components.** All data is resolved at build time. + +Adding an adventure requires only the YAML + per-level `*-posts.json` and registering the id in `ADVENTURE_CATEGORIES` (`scripts/refresh-leaderboard.mjs`). Routes appear automatically via `getStaticPaths()`. --- ## Styling -- Use Tailwind utility classes directly on JSX elements. -- Always check the `@theme` block in `src/index.css` before introducing any new color, font, spacing, or border radius value. Never hardcode these. There is no `tailwind.config.ts`; all theme values live in the `@theme` block in `src/index.css`. -- Both light and dark mode must work. Use the CSS variable pairs (`bg-background`, `text-foreground`) that shadcn sets up. Never hardcode a color that only works in one mode. -- Never add a `dark:` override without a corresponding base (light) style. -- Mobile first. Write base styles for mobile, then add `sm:`, `md:`, `lg:` breakpoints as needed. -- For font utilities, type scale, component class patterns (buttons, pills, badges, overline labels), and animations, see `styleguide.md`. It is the source of truth. Do not duplicate those details here. -- Never write custom CSS unless Tailwind genuinely cannot do the job. If you must, add it to `src/index.css` with a comment explaining why. -- Light mode overrides: do NOT put them inside `@layer base`; rules there are always overridden by `@layer utilities`. Add unlayered rules to the "Light mode overrides" section at the bottom of `src/index.css`, scoped to `.light`. +- Tailwind utilities directly on elements. Check the `@theme` block in `src/styles/index.css` before adding any colour/font/spacing/radius; never hardcode these. +- Both light and dark mode must work. Use the CSS variable pairs (`bg-background`, `text-foreground`). Never add a `dark:` override without a base (light) style. +- Mobile first (`sm:`/`md:`/`lg:`). See `styleguide.md` for the type scale, component classes, and animations (source of truth). +- **Light mode overrides:** add unlayered rules to the "Light mode overrides" section at the bottom of `index.css`, scoped to `.light`. ### Design system rules -- Light mode uses `.light` class on `<html>`, set by the `useTheme` hook. -- Yellow `#ffc034` is accent-only in light mode. Never use it as a text color. -- Dark mode uses `:root` and `.dark`. Never modify these when fixing light mode issues. -- Tailwind `group-hover:*` and `group-focus:*` utilities are not matched by `.light .classname` selectors. Always add explicit `.light .group:hover` rules in the unlayered light mode overrides section of `src/index.css`. -- Avatar palette colors must not be used directly as text colors in light mode. They fail contrast on near-white surfaces. Use `hsl(var(--foreground))` as the text color for avatar initials in all modes. +- Light mode uses `.light` on `<html>`, set by the inline pre-paint script in `Layout.astro` and by `ThemeToggle.astro`. +- Yellow `#ffc034` is accent-only in light mode; never a text colour. +- Dark mode uses `:root`/`.dark`. Never modify these when fixing light mode. --- ## Accessibility -Read [`ACCESSIBILITY.md`](ACCESSIBILITY.md) before writing or modifying any component. It contains the full contributor checklist, WCAG principle reference, and manual testing requirements. +Read [`ACCESSIBILITY.md`](ACCESSIBILITY.md) before writing or modifying any component. WCAG 2.2 AA is the floor, not the goal. -The target is not minimum compliance. Every component must be genuinely usable by keyboard-only users, screen reader users, and people with low vision. WCAG 2.2 AA is the floor, not the goal. +The `e2e/a11y.spec.ts` suite gates every representative route on axe (dark, light, and forced-colors), touch-target size, focus-ring visibility, and 200% zoom reflow. Never reduce the axe tag set `["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22aa","best-practice"]`. Add new routes to `PAGES` in `a11y.spec.ts` and `smoke.spec.ts`. --- ## Analytics and Consent -The site uses Google Analytics 4 with **Consent Mode v2 in gated-load mode**. No data of any kind is sent to Google until the user clicks Accept on the cookie banner; the `gtag.js` script itself is not loaded until that point. Cross-domain measurement between `offon.dev` and `community.offon.dev` is configured in the GA4 admin UI, not in this codebase. - -After Accept, only `analytics_storage` flips to `granted`. The three ad signals (`ad_storage`, `ad_user_data`, `ad_personalization`) stay denied for the lifetime of the site since OffOn does not run Google Ads. - -### Constants - -All analytics-related constants live in `src/data/constants.ts`: - -| Constant | Purpose | -| --- | --- | -| `GA_MEASUREMENT_ID` | GA4 Measurement ID. Used by `useConsent.tsx` only, when it injects `gtag.js` on Accept. The inline bootstrap in `src/root.tsx` does not reference it. | -| `CONSENT_STORAGE_KEY` | `localStorage` key for the consent decision (`analytics_consent`). | -| `CONSENT_EXPIRY_MS` | Stored consent expiry (180 days). Re-prompt the user on the next visit after this. | -| `BRAND_NAME` | Always `"OffOn"`. Never hardcode the string. | -| `BRAND_SHORT_DESCRIPTION` | One-sentence brand description. Used in `Footer.tsx` and meta descriptions. Never hardcode. | -| `BRAND_SLOGAN_PARTS` | Tuple of the three slogan parts: `["Vendor-Neutral", "Open Source", "Community-Driven"]`. | -| `BRAND_SLOGAN` | Full slogan parts joined with `". "`. | -| `BRAND_SECONDARY_LINE_PARTS` | Tuple of the three tagline parts: `["always On.", "always Open.", "always Learning."]`. The `Hero.tsx` heading renders only the first two (`[0]` and `[1]`); `[2]` ("always Learning.") is not part of the hero. | -| `BRAND_SECONDARY_LINE` | Full tagline (all three parts) joined with spaces. Used for the complete brand line in `BottomCTA.tsx` and `BrandGuidelines.tsx`, not the hero. | -| `COMMUNITY_URL` | Real URL of the Discourse instance. Never hardcode. | -| `COMMUNITY_DISPLAY_NAME` | User-facing display name for the community URL. Use for visible text. | -| `CODE_OF_CONDUCT_URL` | Canonical URL of the Code of Conduct topic on Discourse. Use instead of hardcoding `${COMMUNITY_URL}/t/code-of-conduct/31`. | -| `CODESPACES_BASE` | GitHub Codespaces base URL for the challenges repo. Used by `CodespacesButton.tsx`. | -| `CHALLENGES_REPO_URL` | GitHub URL of the challenges repo (`https://github.com/off-on-dev/open-source-challenges`). Use instead of hardcoding. | -| `PROPOSE_ADVENTURE_URL` | Deep link to the adventure ideas section of the challenges repo CONTRIBUTING.md. Use instead of hardcoding. | -| `SITE_URL` | `"https://offon.dev"`. Use for canonical URLs and OG tags. | -| `SITE_NAME` | `"offon.dev"`. | -| `CONTACT_EMAIL` | Contact email address. Used in `CommunityGuide.tsx`. Never hardcode. | -| `LINKEDIN_URL` | LinkedIn company page URL. | -| `BLUESKY_URL` | Bluesky profile URL (`https://bsky.app/profile/off-on-dev.bsky.social`). Used in `Footer.tsx`. | -| `X_URL` | X (Twitter) profile URL (`https://x.com/OffonDev`). Used in `Footer.tsx`. | -| `THEME_STORAGE_KEY` | `localStorage` key for the stored theme preference (`"theme"`). Used by `useTheme.tsx`. | -| `CURRENT_YEAR` | Current calendar year (e.g. `2026`). Update manually each January in `src/data/constants.ts`. | -| `OG_IMAGE_ALT` | Fixed alt text for the `og:image` and `twitter:image` tags. A description of the brand card image (`public/og.png`). Used internally by `buildPageMeta`; callers do not pass it. Never derive this from the page title — the OG image is a fixed brand card, not page-specific art. | - -### How it works - -- `src/root.tsx` contains a minimal inline `<head>` bootstrap that does only three things: bootstrap `window.dataLayer`, define `window.gtag` as the `dataLayer.push` shim, and call `gtag('consent', 'default', {...})` with all four signals denied. **No `wait_for_update`. No localStorage read. No `gtag('js', ...)`. No `gtag('config', ...)`. No `<script src="...googletagmanager...">` tag.** -- `src/hooks/useConsent.tsx` owns the React-side state and the `gtag.js` injector. The injector is shared by both the Accept click path and the mount-restore path, gated by a module-scoped `gtagScriptInjected` boolean so the script tag is appended at most once per session. On Accept, the injector pushes `consent update`, `js`, and `config` into `dataLayer` synchronously **before** appending the script tag, so when `gtag.js` loads it drains the queue in the correct order. The `config` call passes only `cookie_flags: 'SameSite=Lax;Secure'`, `cookie_expires: 15552000` (180 days), and `send_page_view: false`. No `cookie_domain` or `linker`. -- On Decline, the hook pushes `consent update analytics_storage: denied` and clears any `_ga*` cookies. The script tag is **not** removed; `dataLayer` is **not** wiped; `window.gtag` is **not** replaced. `gtag.js` itself stops sending hits when consent is denied. -- On Reset (floating cookie button): same as Decline plus state goes back to `null` and `localStorage` is cleared so the banner reappears. -- `src/components/ConsentBanner.tsx` renders a fixed bottom bar until the user makes a choice. Once consent is set, it renders a floating cookie icon button (bottom-right) that calls `reset()` to reopen the banner. The banner's root `<div>` must keep `aria-live="polite"` so screen readers in virtual cursor mode announce it when it appears after JS hydration. Do not remove it. -- `src/Layout.tsx` mounts `PageViewTracker` and `ClickTracker`. **Both gate on `consent === "granted"`.** Pushing events to `dataLayer` while `gtag.js` is not loaded would queue them, and a later Accept would drain the queue and retroactively send pageviews and click events for routes/clicks the user made while consent was undecided or denied. Gating prevents that. -- `src/hooks/useTheme.tsx` manages the light/dark toggle. Theme is stored in `localStorage` under key `theme`. `ThemeProvider` is mounted in `Layout.tsx`. - -### Consent state machine: enumerate all transitions before touching this code - -| From | To | Trigger | localStorage | React state | gtag.js | dataLayer / cookies | -| --- | --- | --- | --- | --- | --- | --- | -| `null` | `"granted"` | User clicks Accept | write `granted` | `setConsent("granted")` | injected if not already | push `consent update granted` + `js` + `config` | -| `null` | `"denied"` | User clicks Decline | write `denied` | `setConsent("denied")` | not injected | push `consent update denied`, clear `_ga*` cookies (no-op if none) | -| `"granted"` | `"denied"` | Decline after grant | write `denied` | `setConsent("denied")` | unchanged (still loaded) | push `consent update denied`, clear `_ga*` cookies | -| `"denied"` | `"granted"` | Accept after decline | write `granted` | `setConsent("granted")` | injected if not already | push `consent update granted` (+ `js` + `config` only if first injection) | -| `"granted"` | `null` | User clicks Cookie Preferences | clear | `setConsent(null)` | unchanged | push `consent update denied`, clear `_ga*` cookies | -| `"denied"` | `null` | User clicks Cookie Preferences | clear | `setConsent(null)` | unchanged | push `consent update denied` | -| `null` | `"granted"` | Page load with stored `granted` | (read) | `setConsent("granted")` | injected by mount effect | push `consent update granted` + `js` + `config` | -| `null` | `"denied"` | Page load with stored `denied` | (read) | `setConsent("denied")` | not injected | nothing | -| `null` | `"denied"` | Page load, GPC active, no stored preference | write `denied` | `setConsent("denied")` | not injected | clear `_ga*` cookies | -| `"denied"` | `"denied"` | Page load, GPC active, stored `denied` | overwrite `denied` | `setConsent("denied")` | not injected | clear `_ga*` cookies | -| GPC active | `"granted"` | Page load, GPC active, stored `granted` | (read) | `setConsent("granted")` | injected by mount effect | push `consent update granted` + `js` + `config` | - -### Do not +Google Analytics 4 with **Consent Mode v2 in gated-load mode**: no data of any kind is sent to Google until Accept; `gtag.js` is not loaded until then. -- Do not load `gtag.js` outside the consent injector. -- Do not put `gtag('js')` or `gtag('config')` in `root.tsx`. Both belong in the injector, queued after the consent update. -- Do not reintroduce `wait_for_update`. -- Do not remove GPC detection: `navigator.globalPrivacyControl === true` is checked on mount in `useConsent.tsx`. If active and no explicit prior Accept is stored, consent is auto-denied without prompting the user. -- Do not reintroduce `ANALYTICS_LINKER_DOMAINS` or `cookie_domain`. -- Do not put the consent update inside `script.onload`. It must be queued before `appendChild` so the dataLayer drains in the correct order. -- Do not remove the script tag, wipe `dataLayer`, or replace `window.gtag` on deny. -- Do not push `page_view` or `click_event` when consent is not granted. -- Do not skip clearing `_ga*` cookies on deny or reset. +- **`Layout.astro`** contains the minimal inline `<head>` bootstrap: sets up `dataLayer`, defines `window.gtag`, and calls `gtag('consent','default',...)` with all four signals denied. +- **`src/stores/consent.ts`** owns the state (`$consent` atom, default `null`) and the `gtag.js` injector. Read via `.subscribe()`/`.get()` in inline scripts. +- **`src/components/ConsentBanner.astro`** is static markup plus a script that registers under `astro:page-load` and tears down under `astro:before-swap`. ---- +### Do not -## Testing - -- Use Vitest for all unit and integration tests. -- Use `@testing-library/react` for component tests. Test from the user's perspective, not implementation details. -- Test files live in `src/test/` or co-located alongside the module as `*.test.ts(x)`. -- Write tests for all logic in `src/lib/` and `src/hooks/`. Target 80% coverage for new utility and hook files. -- Pure visual components (no state, no side effects) do not require tests. A visual component that holds state or has side effects is not a pure visual component and must have tests. -- Prefer `getByRole` and `getByLabelText` queries over `getByTestId`. They also validate accessibility. -- Never ship code that causes test or lint failures. -- Every new hook, utility function, or stateful component must have tests covering the happy path, edge cases, and all state transitions. -- Tests must be written as part of the implementation, not as an afterthought. -- If a component or hook has side effects (DOM mutations, localStorage, external scripts), mock those side effects in tests and assert they are called correctly. -- When fixing a bug, add a regression test that would have caught it before writing the fix. -- When fixing a bug caused by an incorrect import, file path, or configuration value, add a regression test that asserts on the file's contents. -- Prerender tests live in `src/test/prerender.test.ts` and require a production build. Always run `npm run build` before `npm test` if prerender tests are included. -- Playwright smoke tests live in `e2e/smoke.spec.ts` and require a production build. The axe audit runs with tags `["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"]` in both dark and light mode. Never remove `wcag22aa` from this list. When adding a new prerendered route, add it 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. -- SEO tests live in `src/test/seo.test.ts` and require a production build. When adding a new prerendered route, add it to the `ROUTES` array in `src/test/seo.test.ts`. -- **Visual regression tests** live in `e2e/visual.spec.ts` and require a production build. Run `npm run build && npm run test:visual`. First run generates baseline screenshots in `e2e/__screenshots__/`; subsequent runs compare against baselines and fail if pixel differences exceed threshold. Baselines are committed. To update baselines after intentional visual changes: `npm run test:visual:update`. When adding a new page or making major layout changes, add it to `VISUAL_ROUTES` in `visual.spec.ts` and regenerate baselines. Use `maskSelectors` to hide dynamic content (timestamps, discussion posts) that changes between builds. **These tests are local-only and are not run in CI** (font rendering differs between macOS and Linux). Run them manually before and after any major design change. Never name a smoke test describe block with "visual" in the title, as `--grep visual` routes tests between the two suites. -- When a page renders multiple navigation landmarks, use `within` from `@testing-library/react` to scope queries to the correct landmark before asserting link destinations. -- **Testing hooks with dynamic imports:** Never use `vi.mock` for a dynamic import called inside a hook. Export a loader type and default loader; tests inject `vi.fn().mockResolvedValue(data)` via an optional argument. See `src/hooks/useDiscussionPosts.ts` for the reference implementation. -- **Coverage:** run `npm run test:coverage` for v8 coverage reports. `@vitest/coverage-v8` is installed as a dev dependency. -- **Axe incomplete flags:** When axe reports an "Incomplete" or "Needs Review" result, provide a definitive manual ruling (confirmed violation, confirmed pass, or cannot determine without AT testing) before merging. Do not leave incomplete flags unresolved. Use the `a11y-audit` prompt to evaluate in context. +- Do not load `gtag.js` outside the injector. +- Do not put `gtag('js')`/`gtag('config')` in `Layout.astro`. +- Do not remove GPC detection (`navigator.globalPrivacyControl === true`). +- Do not push `page_view`/`click_event` when consent is not granted. --- -## Hydration and Prerender Safety - -Whether or not the site is prerendered today, these patterns cause bugs. They produce visible flashes in client-only apps and break hydration entirely if the site is ever prerendered. Never introduce them. - -### Do not read browser-only globals during render - -- Never read `window`, `document`, `navigator`, `localStorage`, or `sessionStorage` in a component function body. -- Never read them in a `useState` lazy initializer. -- Correct pattern: initialize state with a safe default, then update it in `useEffect` or `useLayoutEffect`. - -### Do not use non-deterministic values during render - -- Never call `Math.random()`, `Date.now()`, `new Date()`, `crypto.randomUUID()`, or `performance.now()` in a render body. -- `new Date().getFullYear()` in JSX is a common mistake. Use a module-level constant instead. - -### Client-only behavior must be gated - -- Anything that depends on `localStorage`, `matchMedia`, or similar must produce the same initial render as a fresh visitor with no stored state. -- For theme and consent state: always render the default (dark, no-consent) on first render, then update in an effect. -- Always wrap `localStorage` reads and writes in `try/catch`. Storage throws in private browsing and when quota is exceeded. - -### No IntersectionObserver or ResizeObserver at render time - -- Always create observers inside `useEffect`, never at the top level of a component or module. -- Guard any observer that affects rendered content with a `typeof window !== 'undefined'` check. -- Use `useIsomorphicLayoutEffect` instead of `useLayoutEffect` in any component that renders during SSG. - -### entry.server.tsx must use renderToPipeableStream, not renderToString - -- `renderToString` emits `<!--$!-->` markers for any Suspense boundary that suspends during prerender. -- `entry.server.tsx` must always use `renderToPipeableStream` with `onAllReady` callback. -- Never revert to `renderToString` in `entry.server.tsx`. +## Islands and Hydration Safety -### Do not add Suspense wrappers around Outlet in Layout.tsx +These patterns produce hydration mismatches and console errors. Never introduce them. -- Adding `<Suspense>` around `<Outlet />` in Layout.tsx creates an extra boundary React Router does not resolve during prerender, producing broken hydration. -- If you need loading states for routes, configure them in the route module itself. - -### useSearchParams() and prerender hydration - -`useSearchParams()` is safe to call during render, but its value differs between prerender (empty, no URL) and client hydration (real URL params from the browser). Deriving initial `useState` from it causes a mismatch: the prerendered HTML has one value, the hydrating client has another, React throws. Always default to the server-safe value (`false`, `null`, or `[]`) and sync to the real param value in `useEffect`. - -```tsx -// WRONG: lazy initializer reads params at prerender time (always empty) and at -// hydration time (real URL), causing a mismatch. -const [hasFiltered, setHasFiltered] = useState(() => searchParams.has("topics")); - -// CORRECT: start with the server-safe default; sync after mount. -const [hasFiltered, setHasFiltered] = useState(false); -useEffect(() => { if (searchParams.has("topics")) setHasFiltered(true); }, []); // eslint-disable-line react-hooks/exhaustive-deps -``` - -### Stale prerendered data - -Loader functions run at build time. The static `.data` file they produce is frozen until the next build. Any data that depends on the current time (e.g. a deadline that has since passed) will be stale when the page loads in the browser. - -**Pattern:** initialize `useState` from the loader value (correct for hydration — prerendered HTML and first client render agree), then correct in a `useEffect` on mount: - -```tsx -const { myField: initialMyField } = useLoaderData(); -const [myField, setMyField] = useState(initialMyField); - -useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setMyField(recomputeFromCurrentTime()); -}, []); // eslint-disable-line react-hooks/exhaustive-deps -``` - -The `set-state-in-effect` disable is intentional: the mount effect corrects a known staleness in the prerendered value, not a derivation that should live in the render body. Do not suppress the rule for other patterns. - -### JavaScript degradation testing - -Core content must be readable with JavaScript disabled. To verify: DevTools -> Cmd+Shift+P -> "Disable JavaScript" -> reload the page. - -- Page headings, body text, images, and navigation links must be visible and functional. -- Filters, theme toggle, and consent banner may degrade gracefully — they are JS-enhanced features. -- Challenge and adventure text, navigation, and all other primary page content must not be exclusively client-side rendered. -- Run `npm run build` and confirm all content appears in the prerendered HTML files in `dist/client/`. +- **An island's first client render must match its SSR output.** Read `localStorage`/`navigator`/the DOM in `onMounted`, not in `<script setup>` top level or as a `ref` initializer. +- **No non-deterministic values in a render body.** Build-time `.astro` frontmatter may use `new Date()`; Vue island templates must not. +- **After each client navigation** (`astro:after-swap`), `Layout.astro` re-asserts the `<html>` theme class. +- **Progressive enhancement:** core content must render server-side and work with JS disabled. --- ## SEO -This is a fully static React site. Apply these practices on every page. - -### Document structure - -- Every page must have a unique, descriptive `<title>` tag. -- Every page must have a `<meta name="description">` under 160 characters. -- Add Open Graph tags to every page: `og:title`, `og:description`, `og:url`, `og:type`, and `og:image` where an image is available. -- Add Twitter meta tags: always include `twitter:card` (use `summary_large_image` for pages with images), `twitter:title`, `twitter:description`, and `twitter:image`. -- Use React Router v8's `meta()` export on each route module to manage head tags per page. Use the `buildPageMeta` helper from `src/lib/meta.ts`. - -### Heading hierarchy - -- One `<h1>` per page that clearly describes the page topic. -- Headings follow a logical order with no skipped levels. -- For multi-line hero or section headings, do not use `<br />` inside `h1`/`h2`. Use block-level `<span>` elements for visual line breaks. - -### Links and navigation - -- Internal links use React Router `<Link>`. Never trigger full page reloads. -- Use descriptive link text. Never use "click here" or "read more" alone. -- Set the canonical URL for each page as `${SITE_URL}${pathname}` using the `SITE_URL` constant from `src/data/constants.ts`. - -### Performance - -Read [`PERFORMANCE.md`](PERFORMANCE.md) before adding any new dependency, font, image, or route. +Static site. Apply on every page. -### Global head setup (root.tsx) - -- **Required `<head>` elements** -- verify these are present whenever editing `src/root.tsx`: - - `<meta charset="utf-8">` -- must appear in the first 1024 bytes of the HTML, before any non-ASCII content. - - `<meta name="viewport" content="width=device-width, initial-scale=1">` -- tells mobile browsers to render at device width. Never set `user-scalable=no` or `maximum-scale=1`; disabling user zoom breaks WCAG 1.4.4 (Resize Text). - - `<meta name="color-scheme" content="dark light">` -- prevents the white flash dark-mode users see before CSS loads, and lets the browser style scrollbars and native form controls to match the active scheme. -- **Favicons** -- the following files must be present in `public/` and linked from `src/root.tsx`: - - `favicon.svg` -- primary favicon; linked as `<link rel="icon" href="/favicon.svg" type="image/svg+xml">`. - - `favicon.png` -- PNG fallback; linked as `<link rel="icon" href="/favicon.png" type="image/png">`. -- The Organization JSON-LD `"logo"` field in `src/root.tsx` uses `https://offon.dev/brand/offon-logo-dark-color.png` (the full brand logo, not the favicon). Do not revert it to `favicon.png`. - - `favicon.ico` -- ICO fallback for older browsers and the Windows taskbar. Place at `public/favicon.ico` (browsers request it automatically). - - `apple-touch-icon.png` -- 180x180 px PNG; linked as `<link rel="apple-touch-icon" href="/apple-touch-icon.png">`. - - A maskable icon entry in `site.webmanifest` with `"purpose": "maskable"` for Android home screens. - - Verify all five are present before shipping any favicon change. -- Add `<link rel="manifest" href="/site.webmanifest" />` to link the web app manifest. -- Add `<meta name="theme-color">` tags for dark and light mode. -- Add JSON-LD structured data as two `<script type="application/ld+json">` blocks: one `@type: "WebSite"` and one `@type: "Organization"`. The `"OffOn"` brand name is hardcoded as a string literal in both (they cannot reference TypeScript constants inside `dangerouslySetInnerHTML`). Update them manually if the brand name ever changes. -- Always include `og:image:width`, `og:image:height`, and `og:image:alt` for all OG image tags. -- Add `og:site_name` and `og:locale` (en_GB) to all global OG tags in `src/root.tsx`. -- Do not add page-specific meta tags to `src/root.tsx`. These must live in each route module's `meta()` export only. - -### URL structure - -- Keep URLs lowercase, hyphen-separated, and descriptive. Never use underscores or camelCase in URL segments. -- Treat published URLs as a public contract. Once a URL is live, it must keep working. If a URL must change, add a redirect route in `src/routes.ts` pointing the old path to the new one. -- Redirect routes in `src/pages/redirects/` use React Router's `redirect()`. Prefer client-side redirects over broken links. Never chain more than one redirect for the same URL. - -### Soft 404s - -- Every path that does not correspond to a real page must return HTTP 404, not 200. GitHub Pages serves `404.html` automatically for unmatched paths -- no configuration is needed. -- Never create a catch-all route that renders a "page not found" UI with a 200 status. Search engines and AI crawlers treat a 200 response as indexable content. -- When retiring a URL, add a redirect route to its successor. If there is no successor, redirect to the nearest parent or category page. Reserve 404 for paths that were never valid. +- Every page: unique descriptive `<title>`, `<meta name="description">` under 160 chars, and canonical `${SITE_URL}${path}` (trailing slash). One `<h1>`; logical heading order. +- **Per-page meta comes from the `<SEO>` component** (`src/components/SEO.astro`), fed by `Layout.astro` props. Do not hand-write these in pages. +- Internal links use plain `<a href>` with **trailing slashes** and `import.meta.env.BASE_URL`. +- External links: `target="_blank" rel="noopener noreferrer" aria-describedby="new-tab-hint"`. +- Retire URLs via the `redirects` map in `astro.config.mjs`. +- Read [`PERFORMANCE.md`](PERFORMANCE.md) before adding a dependency, font, image, or route. --- @@ -558,66 +325,31 @@ Read [`PERFORMANCE.md`](PERFORMANCE.md) before adding any new dependency, font, ### Brand Name -- The brand is always written **OffOn** (camelCase). Never "offon", "Offon", or "OFFON". -- The community was previously known as "Open Ecosystem". That name is retired. Never use it anywhere. -- In code, always use the `BRAND_NAME` constant from `src/data/constants.ts` instead of hardcoding the string. -- As a URL or href: always `offon.dev` (lowercase, e.g. `<a href="https://offon.dev">`). -- As a display name in prose or UI: `OffOn.dev` is the correct form (brand caps, TLD lowercase). Never capitalise the TLD: `OffOn.Dev` is wrong. +- Always **OffOn** (camelCase). Never "offon", "Offon", or "OFFON". +- "Open Ecosystem" is retired. Never use it. +- In code, use the `BRAND_NAME` constant from `src/lib/site.ts`. +- As a URL/href: `offon.dev` (lowercase). As a display name: `OffOn.dev`. ### Tone -- Direct, positive, and community-focused. -- Write for open source enthusiasts, not a corporate audience. -- Use plain language. Avoid jargon unless it is standard in open source contexts. -- Avoid passive voice where an active one works. -- Keep sentences short and scannable. -- Never enumerate specific difficulty levels (e.g. "Beginner, Intermediate, or Expert") in UI copy. Adventures can have one, two, or three levels at any combination of difficulties. Use broad language instead: "the difficulty that fits where you are", "any difficulty level", or similar. +- Direct, positive, community-focused. Plain language. Active voice. Short, scannable sentences. +- Never enumerate specific difficulty levels in UI copy. ### Capitalisation -All UI labels use **title case (Chicago style)**. Body copy uses **sentence case**. - -**Title case applies to:** button and CTA labels, section headings (h2/h3), card and value titles, navigation labels and footer links, pill and badge text. - -**Title case rule:** capitalise every word except articles (a, an, the), prepositions shorter than five letters, and coordinating conjunctions (and, but, or, nor), unless they open or close the label. - -**Sentence case applies to:** body paragraphs, meta descriptions, `<p>` elements, hero sub-headings, and card descriptions. - -**Exception:** decorative overline labels use CSS `text-transform: uppercase`, so write their source text in plain lowercase. +UI labels use **title case (Chicago)**; body copy uses **sentence case**. ### Formatting -- Never use em dashes anywhere, including comments and documentation. Use commas, periods, or restructure the sentence instead. -- Maintain a cohesive tone across all pages and components. -- Do not mix formal and casual registers within the same page. - -### External URLs - -- `LINKEDIN_URL` in `src/data/constants.ts` contains the current LinkedIn company page URL. Update it when the LinkedIn company page URL changes. +- Never use em dashes anywhere (comments and docs included). Use commas, periods, or restructure. --- ## Git -- Branch naming: `type/short-description` (e.g. `feat/hero-section`, `fix/nav-scroll`). -- All commits must be signed off: `git commit -s`. -- Never force-push to `main`. -- PR titles follow conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`. - -### Commit types - -| Type | When to use | -| --- | --- | -| `feat` | New feature | -| `fix` | Bug fix | -| `docs` | Documentation only | -| `style` | CSS or formatting changes | -| `refactor` | Code restructure, no feature or fix | -| `chore` | Maintenance, dependencies | -| `perf` | Performance improvements | -| `security` | Security fixes | -| `config` | Configuration changes | -| `revert` | Reverting a previous commit | +- Branch naming: `type/short-description` (e.g. `feat/hero-section`). +- All commits signed off: `git commit -s`. +- Never force-push to `main`. PR titles follow conventional commits. --- @@ -625,154 +357,77 @@ All UI labels use **title case (Chicago style)**. Body copy uses **sentence case ### Well-known files -- `public/.well-known/security.txt` contains an `Expires` field. Update the date annually (current expiry: `2027-06-01`). An expired security.txt is treated as absent by scanners. -- `public/llms.txt` lists key pages and all live adventures. Update it whenever a new adventure is added (step 7 in the adventure checklist above) or a page is significantly renamed. -- `public/llms-full.txt` is the extended companion to `llms.txt`. It contains full level-by-level detail for every adventure. Update it whenever a new adventure or level is added, or a level's technologies/description changes. -- `public/robots.txt` lists named AI crawler agents. No routine updates needed; add a new agent entry only when a major crawler publishes a new user-agent string. Note: robots.txt does not support inheritance — named `User-agent` groups do not inherit `Disallow` rules from `User-agent: *`. When adding a new path to exclude, repeat the `Disallow` line in every group. -- `public/.well-known/agent-skills/offon/SKILL.md` describes the site to AI agents. Update it if the site's key URLs, adventure list, or technology list changes significantly. After editing it, recompute the SHA256 digest (`shasum -a 256 public/.well-known/agent-skills/offon/SKILL.md`) and update the `digest` field in `public/.well-known/agent-skills/index.json`. A stale digest makes the file unverifiable to compliant agents. -- `public/.well-known/api-catalog` lists machine-readable resources. Update it if a new resource endpoint is added (e.g. a new feed or data file). +- `public/.well-known/security.txt` `Expires` — update annually (current: `2027-06-01`). +- `public/llms.txt` / `llms-full.txt` — update when an adventure/level is added or a page renamed. +- `public/robots.txt` — named `User-agent` groups do not inherit `Disallow` from `*`; repeat `Disallow` in each group. +- `public/.well-known/agent-skills/offon/SKILL.md` — after editing, update the SHA256 `digest` in `index.json`. ### Sitemap -- Every time a new static page is added to `src/pages/` and registered as a route in `src/routes.ts`, its URL must also be added to `public/sitemap.xml` with a `<lastmod>` date. **Exception:** legal/policy pages (`/privacy/`) are intentionally excluded from the sitemap. Do not add them back. -- Dynamic routes with statically known IDs must also be added to `public/sitemap.xml` with a `<lastmod>` date. Adventure and challenge-tag URLs are generated automatically by `scripts/generate-adventures.mjs` and include `<lastmod>` set to the build date; do not add them by hand. -- `robots.txt` at `public/robots.txt` must include: `Sitemap: https://offon.dev/sitemap.xml` -- **Generator region markers:** `scripts/generate-adventures.mjs` uses XML comment markers to patch adventure and tag entries into `public/sitemap.xml` (see `replaceRegion` calls near line 1204 and 1251). The markers are `<!-- GENERATED:adventures -->` / `<!-- /GENERATED:adventures -->` for the adventures block and `<!-- GENERATED:challenge-tags -->` / `<!-- /GENERATED:challenge-tags -->` for the tags block. Do not remove, rename, or reorder these comments. If they are missing, `npm run build` aborts with "Region markers not found". - -### SSG prerendered routes +`/sitemap.xml` is generated at build time by `src/pages/sitemap.xml.ts`. When adding a new **static** page, add its path to the `staticPaths` array in that endpoint (except noindex pages). -- The list of routes React Router v8 prerenders is in the `prerender` array inside `react-router.config.ts`. -- When adding a new static route, add it to **all three** of: `src/routes.ts`, `public/sitemap.xml`, and the `prerender` array in `react-router.config.ts`. +### Routes -When adding a new route to `src/routes.ts`, follow these rules by route type: +When adding a page, add it to `PAGES` in `e2e/a11y.spec.ts` and `ROUTES` in `e2e/smoke.spec.ts`, to the `staticPaths` array in `src/pages/sitemap.xml.ts`, and to the routes table in `README.md`. -- Static routes: add to `public/sitemap.xml`, the routes table in `README.md`, and the `prerender` array in `react-router.config.ts`. -- Dynamic routes with statically known IDs: add individual URLs to `public/sitemap.xml`, the `prerender` array, and `README.md`. Also create a per-level discussion JSON file if the level has a discussion thread. -- Redirect routes: do not add to `sitemap.xml` or `README.md`. -- Catch-all routes: do not add anywhere. +### Adding an adventure or level -### When adding a new adventure or a new level to an existing adventure - -See [`ADVENTURES.md`](ADVENTURES.md) for the full sync process and PR checklist. The **Sync Adventure** workflow handles routes, sitemap, prerender entries, test arrays, and `public/llms.txt` automatically. The two manual steps before merging are: - -1. Update the routes table in `README.md`. -2. Run `npm run generate`, then commit `public/llms.txt` to the PR branch (the sync workflow modifies it but does not stage it). +See [`ADVENTURES.md`](ADVENTURES.md). Add/extend the YAML, add per-level `*-posts.json`, register the id in `ADVENTURE_CATEGORIES`, and add the new URLs to the test route lists, `README.md`, and `public/llms.txt`. --- ## Deployment -- Push to `main` triggers `deploy.yml` and deploys to GitHub Pages. -- Open PRs trigger `preview.yml` and create a PR preview deployment. -- 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 `/`). Never change this without verifying GitHub Pages routing. - -### Trailing slashes and `_.data` aliases - -GitHub Pages normalises every URL to a trailing slash (e.g. `/adventures/lex-imperfecta` becomes `/adventures/lex-imperfecta/`). All internal `Link to` props use trailing slashes to stay consistent with the URL the browser shows. - -React Router v8 defaults to `trailingSlashAwareDataRequests`. When the current URL has a trailing slash, single-fetch data requests use `<path>/_.data` instead of `<path>.data`. The prerender only generates `<path>.data` files, so a `_.data` request would 404. - -The `postbuild` script (`scripts/create-data-aliases.mjs`) runs automatically after every `npm run build`. It copies each `*.data` file to `<name>/_.data` so both URL formats resolve. Example: - -```text -dist/client/adventures/lex-imperfecta.data # non-trailing-slash request -dist/client/adventures/lex-imperfecta/_.data # trailing-slash request (GitHub Pages) -``` - -`serve.json` at the repo root sets `trailingSlash: true` so `npm run preview` mirrors GitHub Pages behaviour locally. It is not in `public/` and is not served in production. - -**Never remove trailing slashes from `Link to` props.** That would make client-side navigation inconsistent with the URL GitHub Pages shows in the browser. - -### PR preview static assets - -The `preview.yml` copy step explicitly lists every static asset directory and root-level file type that needs to appear in the PR preview. Vite copies `public/` to `dist/client/` during the build, but `preview.yml` then copies those files into the `dist/client/pr-preview/pr-N/` subdirectory that `rossjrw/pr-preview-action` deploys. - -**When adding a new directory or root-level file type to `public/`, you must also add a corresponding copy line in the copy step of `.github/workflows/preview.yml`.** If you forget, the file will exist in production but return 404 in all PR previews. - -Current copy step covers: `assets/`, `fonts/`, `reveal/`, `team/`, `speakers/`, `brand/`, `solutions/`, `downloads/`, `qr/`, `screenshots/`, `deck/`, `deck-template/`, and root-level `*.svg`, `*.png`, `*.ico`, `*.webmanifest`, `*.webp` files. `serve.json` lives at the repo root, not in `public/`, and is not copied here. +- Push to `main` triggers `deploy.yml` → GitHub Pages via `JamesIves/github-pages-deploy-action`. +- Open PRs trigger `preview.yml`. The build outputs `dist/`; `JamesIves/github-pages-deploy-action@v4` publishes it to `gh-pages`. +- `trailingSlash: 'always'` matches GitHub Pages URL normalization. +- **PR previews** build with `VITE_BASE_PATH=/pr-preview/pr-N/`; `Layout.astro` marks these builds `noindex`. ### 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` | any tag | -| `actions/cache` | any (GitHub-created, covered by org checkbox) | -| `actions/setup-node` | any tag | -| `actions/create-github-app-token` | any tag | -| `JamesIves/github-pages-deploy-action` | any tag | -| `marocchino/sticky-pull-request-comment` | any tag | -| `rossjrw/pr-preview-action` | any tag | -| `fsfe/reuse-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) or native shell commands. +The `off-on-dev` org restricts third-party actions. Permitted: `actions/checkout`, `actions/cache`, `actions/setup-node`, `actions/create-github-app-token`, `JamesIves/github-pages-deploy-action`, `marocchino/sticky-pull-request-comment`, `rossjrw/pr-preview-action`, `fsfe/reuse-action`, actions owned by `off-on-dev`, actions created by GitHub, and Marketplace-verified actions. `withastro/action` and `actions/deploy-pages` are **NOT allowlisted**. Before adding a `uses:`, verify it is permitted. --- ## Before Submitting Code -Every code change must pass all of these checks before being considered done. State the result of each check explicitly before finishing a task. +State the result of each check explicitly before finishing. -### Mandatory checks +1. **Content gate:** `npm run sync` passes (Zod schema over adventure YAML). +2. **Types:** `npm run check` (`astro check`) passes with zero errors. +3. **Lint:** `npm run lint` passes. +4. **REUSE lint:** `npm run lint:reuse` passes. +5. **Build:** `npm run build` completes with no errors. +6. **Unit tests:** `npm run test:unit` passes. +7. **e2e + a11y:** `npm run test:e2e` passes. Kill any stray server on port 4321 first. +8. **Re-read every file you changed;** verify the final state. +9. **Check call sites** for any changed prop/type/export. +10. **Verify at 375 / 768 / 1280px** against the production build (`npm run preview`). -1. **Run lint:** `npm run lint` must exit with zero errors. -2. **Run REUSE lint:** `npm run lint:reuse` must pass. Requires `pip install reuse` once. Run whenever a new file type or extension is added to the repo. -3. **Run tests:** `npm test` must pass with zero failures. -4. **Run e2e and a11y tests:** `npm run build && npm run test:e2e` must pass with zero failures. The axe audit runs tags `["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"]` in both light and dark mode. Never reduce this tag set. Axe catches roughly 30-40% of real issues — treat it as ground truth for mechanical violations, but manual persona testing (see ACCESSIBILITY.md) is always required. -5. **Run build:** `npm run build` must complete with no TypeScript errors or bundling failures. -6. **Re-read every file you changed:** verify the final state is correct. Never assume an edit landed correctly without checking. -7. **Check all call sites:** if you changed a function signature, component props, or exported type, search for all usages and confirm they are updated. -8. **Check imports:** every import must resolve. No unused imports. No circular dependencies introduced. -9. **Verify at three viewports:** 375px, 768px, and 1280px. Always test against the production build, never the dev server. -10. **Check discussion data on every PR:** if the PR adds or modifies adventure levels, verify that a per-level discussion JSON file exists with the correct `discussionUrl`. +### Red flags — stop and flag to the user -### Before writing any code - -1. Read the relevant files first. Never edit a file you have not read in this session. -2. If the change touches more than one file, list all affected files before starting. -3. If the change involves a state machine, enumerate all transitions first. -4. If the change involves shared state, confirm a context provider is used. -5. If the change involves a side effect (DOM, localStorage, external scripts), write the test before or alongside the implementation. - -### Red flags that require stopping and flagging to the user - -- A fix requires changing more than 3 files you did not plan to change. -- A type error requires adding a cast or suppression to resolve. -- A test requires mocking something that was not mocked before. -- The same bug has been fixed more than once in this session. -- A replacement did not change the file (silent no-op). -- The error in the browser console shows a different bundle hash than the latest build output. -- A "fix" has been applied but the same error reproduces unchanged. +- A fix touches more than 3 files you did not plan to change; a type error needs a cast/suppression; the same bug is "fixed" more than once; a replacement is a silent no-op; a browser error shows a stale asset hash. --- ## Do Not -- Do not add a backend, API routes, or server-side rendering. -- Do not add external font or icon CDN links. All assets must be self-hosted. -- Do not change `vite.config.ts` base path without verifying GitHub Pages routing. -- Do not install new dependencies without checking if shadcn/ui or an existing utility covers the need. +- Do not add a backend, API routes, or SSR (`output` stays `static`). +- Do not add external font or icon CDN links; all assets self-hosted. +- Do not change `base` handling without verifying GitHub Pages + PR-preview routing. +- Do not install a new dependency without checking an existing lib/primitive covers it. - Do not commit secrets, tokens, or credentials. -- 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. -- Do not re-derive data from `ADVENTURES` inside component files. -- Do not edit `*.generated.ts`, `src/data/adventures/index.ts`, or `src/data/adventures/summaries.ts` by hand. +- Do not change the `@theme` block in `src/styles/index.css` without verifying it doesn't break components. +- Do not edit adventure data types by hand; the YAML and the Zod schema are the source of truth. +- Do not add `@astrojs/react`, `react`, or `react-dom` — Vue is the island framework. --- ## When Suggesting Code -- Always read `styleguide.md` before making any UI, copy, or component changes. -- Follow all rules in the Styling and Components sections. -- Flag any accessibility concerns before writing the code, not after. Read `ACCESSIBILITY.md` first. -- Flag any breaking changes explicitly. -- Prefer simple, readable solutions over clever ones. -- If something could be done multiple ways, briefly explain the tradeoff and recommend one approach. +- Read `styleguide.md` before UI/copy/component changes. +- Flag accessibility concerns before writing code (read `ACCESSIBILITY.md`). Flag breaking changes explicitly. +- Prefer simple, readable solutions. If multiple approaches exist, state the tradeoff and recommend one. --- @@ -780,26 +435,12 @@ Every code change must pass all of these checks before being considered done. St A task is not done until the relevant docs are updated. -### Always check these four things after any non-trivial change +1. New/changed component, island, or utility? Update `styleguide.md`. +2. New/changed page or route? Update the routes table in `README.md` and the test route lists + sitemap. +3. New/changed constant or config value? Document it in `README.md`. +4. Changed a build/deploy/dev workflow? Update Commands in `CLAUDE.md` and `README.md`. Keep `AGENTS.md` in sync. -1. **Did you add or change a component, hook, or utility?** Update `styleguide.md`. -2. **Did you add or change a page or route?** Update the routes table in `README.md`. -3. **Did you add or change an environment variable, constant, or config value?** Document it in `README.md`. -4. **Did you change a build, deploy, or dev workflow?** Update the Commands section in `AGENTS.md`, keep `CLAUDE.md` in sync, and update `README.md`. - -After completing any task, explicitly state which checks applied, what was updated, or why it was skipped. - -| Change | Update | -| --- | --- | -| New component | styleguide.md: component entry with props and usage | -| New hook | styleguide.md: hook entry with return type and behavior | -| New utility function | styleguide.md: brief entry if it affects patterns | -| New page or route | README.md routes table; sitemap.xml and prerender array for static routes | -| New constant | README.md constants section, styleguide.md if visual | -| New workflow step | README.md commands section, AGENTS.md (keep CLAUDE.md in sync) | -| New brand or copy rule | styleguide.md first, then apply across codebase | -| Bug fix that reveals a missing rule | AGENTS.md: add the rule to prevent recurrence (keep CLAUDE.md in sync) | -| New test pattern | AGENTS.md: add to Testing section if it sets a precedent (keep CLAUDE.md in sync) | +State which checks applied and what was updated (or why skipped). --- @@ -807,44 +448,12 @@ After completing any task, explicitly state which checks applied, what was updat ### Shared state -If a hook or piece of state is consumed by more than one sibling component, it must be a React context provider, not a plain hook. +State consumed by more than one island lives in a **nanostore** (`src/stores/`), read via `.subscribe()`/`.get()` in inline scripts. When the first Vue island needing shared state is added, install `@nanostores/vue` and use `useStore` from it. ### File extensions -Any file that renders or returns JSX must use the `.tsx` extension. Files that are pure TypeScript logic with no JSX use `.ts`. - -### React hooks - -Each `useEffect` must have a single responsibility. Never combine side effects with different trigger conditions into one effect. Split them. - -Every `useEffect` that creates a `setTimeout`, `setInterval`, or event listener must return a cleanup function that cancels it. Clear and reassign timer refs before setting a new one so rapid re-fires don't stack. Example: - -```tsx -const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); -useEffect(() => (): void => { - if (timerRef.current !== null) clearTimeout(timerRef.current); -}, []); -// in handler: -if (timerRef.current !== null) clearTimeout(timerRef.current); -timerRef.current = setTimeout(() => setState(false), 1500); -``` +Static, zero-JS UI is `.astro`. Interactive islands are `.vue`. Pure logic is `.ts`/`.mjs`. Build-time-only pipeline modules are `.mjs`. ### State machines -When implementing any feature with multiple states, enumerate every transition before writing code. For each transition, list every system that must be updated (storage, UI state, external APIs, DOM). - ---- - -## SEO Checklist: Required for Every New Page - -Add via the route module's `meta()` export, never in `src/root.tsx`: - -- `<title>` (unique) and `<meta name="description">` (under 160 chars) -- `og:title`, `og:description`, `og:url`, `og:type`, `og:image`, `og:image:width` (1200), `og:image:height` (630), `og:image:alt`, `og:site_name`, `og:locale` (en_GB) -- `twitter:card` (`summary_large_image`), `twitter:title`, `twitter:description`, `twitter:image`, `twitter:image:alt` -- `<link rel="canonical">` set to `${SITE_URL}${pathname}` -- Correct heading hierarchy: one `h1`, `h2` for sections, `h3` for subsections - -Static routes only: add to `public/sitemap.xml` and the `prerender` array in `react-router.config.ts`. - -One-time `src/root.tsx` check (not per page): manifest link, both theme-color tags, JSON-LD block, `lang="en"` on `<html>`. +Enumerate every transition before writing code. For each, list every system that must update (localStorage, store state, DOM, `gtag`/dataLayer). The consent machine table in `CLAUDE.md` is the reference. diff --git a/CLAUDE.md b/CLAUDE.md index 396c47b55..05ed68eb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,45 +17,35 @@ Project-level Claude Code commands live in `.claude/commands/`. Invoke them with |   `/navigation` | Sub-command: working on nav components — primary nav, skip links, breadcrumbs, pagination, mobile menus. | |   `/progressive-enhancement` | Sub-command: building any new feature or reviewing architecture. Ensures core content works without JS. | |   `/user-personalization` | Sub-command: working on theme toggle, consent state, or any user preference persistence. | -| `/add-solution` | Generate a structured TypeScript solution file from any input format (md, YAML, HTML, plain text). Downloads and converts images to WebP. | +| `/add-solution` | Generate a structured TypeScript solution file (`src/data/solutions/<id>/<level>.ts`) from any input format. Downloads and converts images to WebP. Solutions are pre-built TS objects loaded by the app; there is no generator step. | | `/create-presentation` | Create a presentation deck for an OffOn event or challenge. Supports two formats: Reveal.js HTML (`public/deck-template/index.html`) and editable PowerPoint PPTX (edit and run `.ai/templates/generate-pptx.mjs`). Reveal.js output goes to `public/<event-slug>/index.html`; PPTX outputs to `public/downloads/offon-deck-template.pptx`. | -The `spec-first-coding` command is installed globally (`~/.claude/skills/`) and is not in this repo. It enforces W3C spec citations before generating any accessibility-related code. - -Use `/a11y-audit` for all accessibility audits in this repo. The four sub-commands can also be invoked directly when working in their specific domain. - ---- - -## Icons - -- Always use **lucide-react** for all icons. Do not add any other icon library. -- Decorative icons next to visible text: `aria-hidden="true"`, no `aria-label`. -- Icon-only interactive elements: add `aria-label` to the parent element, do not use `aria-hidden`. -- When placing an icon next to text in a link or button, always add `inline-flex items-center gap-1` to the container. A lone icon inside a plain `inline` element drops below the text baseline. -- See the Icons section of `styleguide.md` for the full icon map, size conventions, and current usage. -- **Brand/social icon exception:** Official brand SVGs (e.g. the LinkedIn "in" mark) are exempt from the lucide-react-only rule when no equivalent exists in lucide-react. Place the SVG inline, set `aria-hidden="true"` on the `<svg>` element, and put `aria-label` on the parent interactive element. Use `fill="currentColor"` so hover and theme color changes apply. Document every brand SVG addition in the Icon map table in `styleguide.md`. Current exceptions: LinkedIn icon in `Footer.tsx`. +The `spec-first-coding` command is installed globally (`~/.claude/skills/`). Use `/a11y-audit` for all accessibility audits. --- ## Project Overview -**offon.dev** is the main website for OffOn, a platform for open source enthusiasts. -It is fully static with no backend and no database. Pages are prerendered at build time using React Router v8 framework mode (`ssr: false`). +**offon.dev** is the main website for OffOn, a platform for open source enthusiasts. It is fully static with no backend and no database. Pages are prerendered at build time by **Astro** (`output: 'static'`); interactivity is layered on as **Vue islands**. + +> This project was migrated from React Router v8 to Astro + Vue. If you find a reference to `root.tsx`, `entry.server`, `routes.ts`, `*.generated.ts`, `useConsent`, `useTheme`, or `scripts/generate-adventures.mjs`, it is stale — those no longer exist. -Community activity happens on a separate Discourse instance. Its display name is **community.offon.dev**, but the real URL is managed via the `COMMUNITY_URL` constant in `src/data/constants.ts`. Do not hardcode it. Do not attempt to replicate or integrate Discourse functionality here. +Community activity happens on a separate Discourse instance (display name **community.offon.dev**). Use the `COMMUNITY_URL` constant from `src/lib/site.ts`; never hardcode it. Do not replicate or integrate Discourse functionality here. --- ## Stack -- **Framework:** React 19 with TypeScript, bundled via Vite. Check `package.json` for current versions. -- **Styling:** Tailwind CSS 4, configured CSS-first via `src/index.css` (`@theme` block). There is no `tailwind.config.ts`; it was deleted as part of the Tailwind 4 migration. -- **Components:** Minimal shadcn/ui surface. `src/components/ui/` contains only `badge.tsx` and `tooltip.tsx`. Most Radix UI packages were intentionally removed. -- **Routing:** React Router v8 framework mode (static prerendering with `ssr: false`) -- **Testing:** Vitest + @testing-library/react (unit/component); Playwright (smoke tests in `e2e/`) -- **Hosting:** GitHub Pages -- **PR previews:** pr-preview-action -- **Node.js:** 26 is required. Version is pinned in `.nvmrc`. Run `nvm use` to switch automatically. +- **Framework:** Astro 7 (static output), TypeScript. Check `package.json` for versions. +- **Interactivity:** `@astrojs/vue` is installed and ready for Vue 3 islands, but the site currently ships **zero islands**. All interactive surfaces are `.astro` components with vanilla `<script>` blocks. Adding the first island is a one-file change. Shared store state uses **nanostores** (`src/stores/`), read directly via `.subscribe()`/`.get()` in inline scripts — not via `@nanostores/vue` (which is not installed; add it when the first Vue island needing shared state is created). +- **Styling:** Tailwind CSS 4, CSS-first via `src/styles/index.css` (`@theme` block) and the `@tailwindcss/vite` plugin. No `tailwind.config.ts`. +- **Icons:** `unplugin-icons` (lucide set via `@iconify-json/lucide`) in both `.astro` and `.vue` islands. +- **UI primitives:** No shared component library. The abbreviation tooltip is a plain JS portal in `Layout.astro` (position:fixed, escapes overflow clipping). There is no shadcn or Reka UI surface. +- **Content:** Astro Content Collections (Zod-validated) over authored YAML. See "Content collection". +- **Routing:** Astro file-based routing + `getStaticPaths()`. Trailing slashes always. +- **Testing:** Playwright + `@axe-core/playwright` in `e2e/` (a11y + SEO/smoke/hydration). +- **Hosting:** GitHub Pages. **PR previews:** `rossjrw/pr-preview-action`. +- **Node.js:** 26 (pinned in `.nvmrc`; `nvm use`). --- @@ -65,28 +55,27 @@ Community activity happens on a separate Discourse instance. Its display name is | Thing | Convention | Example | | --- | --- | --- | -| Component files and exports | PascalCase | `FilteredLevelCard.tsx`, `export const FilteredLevelCard` | -| Hook files and exports | camelCase, `use` prefix | `useTheme.tsx`, `export function useTheme` | -| Module-level constants from static data | SCREAMING_SNAKE_CASE | `ADVENTURES`, `ALL_TAGS` | -| Route segments | kebab-case | `community-guide`, `adventure-detail` | +| Astro components / pages | PascalCase files (components), kebab or `[param]` (pages) | `AdventureCard.astro`, `adventures/[id].astro` | +| Vue island components | PascalCase | `MyFeature.vue` (no islands exist yet; convention is ready) | +| nanostores | camelCase file, `$`-prefixed export | `stores/consent.ts` → `$consent` | +| Module-level constants | SCREAMING_SNAKE_CASE | `BRAND_NAME`, `DIFFICULTIES` | +| Route segments | kebab-case | `presentation-templates`, `handbook` | ### What lives where -- Logic derived from `ADVENTURES` belongs in `src/data/adventures/index.ts`, exported, and imported everywhere. Do not re-derive it in component files. -- Reusable card or list markup belongs in `src/components/`, not duplicated inline. Extract before the second copy appears. -- Redirect routes that share a destination share a single file in `src/pages/redirects/`. The filename describes the destination, not the source (e.g. `HandbookRedirect.tsx`). +- Adventure data is derived from the `adventures` content collection (`getCollection('adventures')`). Do not re-derive collection logic ad hoc in pages; put shared derivations in `src/lib/` (e.g. `challenges.ts`, `adventure-derive.mjs`). +- Reusable markup belongs in `src/components/` (`.astro` for static, `.vue` for islands). Extract before the second copy appears. +- Retired URLs are handled by the `redirects` map in `astro.config.mjs`, not by page files. --- ## URLs and External Organisations -- The canonical domain for this site is <https://offon.dev>. -- og:url, og:image, and all absolute URLs must use <https://offon.dev>. -- The og:image file is public/og.png and its full URL is <https://offon.dev/og.png>. Its dimensions are 1200 x 630 px. -- PR preview deployments are served from the gh-pages branch under /pr-preview/pr-{number}/. -- The open source challenges content lives in a separate organisation at <https://github.com/off-on-dev/open-source-challenges>. This is an intentional external link and must never be changed or flagged as a violation. -- The community Discourse instance is at <https://community.offon.dev>. Use the `COMMUNITY_URL` constant from `src/data/constants.ts`, never hardcode this URL. -- `COMMUNITY_DISPLAY_NAME` is defined in `src/data/constants.ts` as the user-facing display name for the community URL. Use it for visible text, use `COMMUNITY_URL` for href attributes. +- The canonical domain is <https://offon.dev>. og:url, og:image, and all absolute URLs must use it. +- The og:image is `public/og.png` (<https://offon.dev/og.png>), 1200 x 630 px. +- PR previews are served from the gh-pages branch under `/pr-preview/pr-{number}/`. +- The open source challenges content lives at <https://github.com/off-on-dev/open-source-challenges> (intentional external link; never flag it). +- The community Discourse instance is <https://community.offon.dev>. Use `COMMUNITY_URL` from `src/lib/site.ts`; never hardcode. Use `COMMUNITY_DISPLAY_NAME` for visible text, `COMMUNITY_URL` for hrefs. --- @@ -94,42 +83,33 @@ Community activity happens on a separate Discourse instance. Its display name is ```text src/ - components/ # Reusable UI components (named exports, PascalCase files) - pages/ # Route-level page components - data/ # Static data files (TypeScript objects/arrays) - hooks/ # Custom React hooks - lib/ # Shared utilities - assets/ # Static assets bundled by Vite - Layout.tsx # App shell: providers, skip nav, scroll-to-top, consent banner, and Outlet + pages/ # File-based routes (.astro). Dynamic routes use getStaticPaths(). + index.astro # Home + adventures/[id].astro, adventures/[id]/levels/[levelId].astro (+/solution.astro) + challenges/[...tag].astro, 404.astro, and the static pages + _app.ts # Vue appEntrypoint (island-wide setup) + layouts/ + Layout.astro # App shell: <head> (SEO, CSP, favicons, theme + GA4 bootstrap, JSON-LD), + # ClientRouter, skip-nav, Navbar, <slot/>, Footer, ConsentBanner + components/ # *.astro (static, zero-JS) and *.vue (islands) + content.config.ts # Content collection: Zod schema + custom loader + markdown rendering + data/ + adventures/<id>/adventure.yaml + <level>-posts.json + leaderboard.json + adventures/contributors.ts, types.ts + solutions/<id>/<level>.ts (pre-built Solution objects), sponsors.ts, team.ts + lib/ # markdown-pipeline.mjs, adventure-derive.mjs, community-data.ts, + # solutions.ts, challenges.ts, difficulty.ts, markdown.ts, utils.ts, + # site.ts (constants), level-constants.mjs, deadline.mjs + stores/ # nanostores: consent.ts ($consent + gtag injector) + styles/index.css # Tailwind @theme, component classes, light-mode overrides + assets/diagrams/ # Architecture SVGs (imported per-level via import.meta.glob) e2e/ - smoke.spec.ts # Playwright smoke tests - a11y.spec.ts # Axe-core accessibility audit (dark and light mode) - hydration.spec.ts # React hydration checks - visual.spec.ts # Visual regression tests (local only, not run in CI) - wsg.spec.ts # Well-known/agent-skills verification -public/ - fonts/ # Self-hosted fonts (Inter, Syne, JetBrains Mono) - brand/ # OffOn brand assets (SVG + PNG logos, Nyx illustrations). Referenced by deck/index.html and BrandGuidelines.tsx. - team/ # Board member photos (*.webp). Used by BoardSection and deck/index.html host slides. Do not duplicate into src/assets/. - speakers/ # Event speaker photos (*.webp). Used by presentation decks only. Speakers are per-event and distinct from board members. - solutions/ # Solution walkthrough screenshots, one subdirectory per adventure ID (e.g. solutions/echoes-lost-in-orbit/). Referenced by src/data/solutions/ with absolute paths. - reveal/ # Self-hosted Reveal.js 6.0.1 library. Used by deck/index.html, deck-template/index.html, and all generated Reveal.js decks. - deck/ # Reveal.js presentation for Open Source Talks events (public/deck/index.html). Served at /deck/. All asset paths use ../ to resolve sibling directories correctly regardless of trailing-slash normalization. - deck-template/ # Boilerplate template for /create-presentation (Reveal.js format). Edit deck-template/index.html to update the design system for all future decks. Asset paths use ../ so the file works both from the dev server (/deck-template/) and inside the standalone ZIP. - nyx.webp # Nyx mascot illustration. Referenced in BottomCTA and About via import.meta.env.BASE_URL. - nyx_peek.webp # Nyx peek variant. Referenced in About via import.meta.env.BASE_URL. -.github/ - workflows/ - 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 - refresh-community-sitemap.yml # Daily community sitemap regeneration - sync-adventure.yml # workflow_dispatch: sync an adventure from the challenges repo - validate-adventures.yml # PR check: validates adventure YAML, routes, and sitemap consistency - validate-docs.yml # PR check: ensures styleguide.md/README.md updated with code changes - add-discussion-url.yml # workflow_dispatch: set discussionUrl for a level and fetch initial posts - a11y-scan.yml # Scheduled weekly accessibility scan (Monday 08:00 UTC) - reuse.yml # REUSE licence compliance check on push and PR + a11y.spec.ts # axe (dark/light/forced-colors) + touch targets + focus rings + zoom + smoke.spec.ts # per-route title/canonical/OG/h1 + island hydration +public/ # copied verbatim to dist/ (fonts, favicons, brand, well-known, decks, etc.) +astro.config.mjs, tsconfig.json, playwright.config.ts, package.json +.github/workflows/ # deploy, preview, validate-adventures, sync-adventure, + # add-discussion-url, refresh-community-*, a11y-scan, reuse ``` --- @@ -137,417 +117,202 @@ public/ ## Commands ```sh -nvm use # Switch to Node 26 (required) -npm run dev # Start local dev server (http://localhost:8080) -npm run build # Production SSG build (React Router v8) -> dist/client/ -npm run build:dev # Dev-mode build -npm run lint # ESLint -npm run lint:reuse # REUSE licence compliance (requires: pip install reuse) -npm test # Run tests once (Vitest) -npm run test:watch # Tests in watch mode -npm run test:coverage # Run tests with v8 coverage (uses @vitest/coverage-v8) -npm run test:e2e # Playwright smoke, a11y, hydration, and wsg tests (requires npm run build first) -npm run test:visual # Visual regression tests (requires npm run build first) -npm run test:visual:update # Update visual baseline screenshots -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 generate:solutions # Regenerate solution barrel index from src/data/solutions/ -npm run generate:solutions:validate # Validate solution files without writing the barrel index - -npx shadcn@latest add <component> # Add a shadcn/ui component +nvm use # Node 26 +npm run dev # Astro dev server (http://localhost:4321) +npm run build # Static build -> dist/ +npm run preview # Serve the built dist/ (astro preview) +npm run sync # astro sync — runs the Zod content schema; fails on invalid adventure YAML +npm run test:unit # Vitest unit tests (lib, stores, Vue components) — fast, no server needed +npm run test:e2e # Playwright (a11y + smoke). Runs `astro preview` itself; no separate build needed +npm run lint:reuse # REUSE licence compliance (requires: pip install reuse) [if present] +rm -rf .astro # Bust the content collection pipeline cache (after editing markdown-pipeline.mjs or adventure-derive.mjs) # Regenerate downloadable presentation ZIPs and PPTX (run from repo root) +# jszip is a devDependency, so this runs after a plain `npm ci`. reveal.js itself +# is not needed: the deck assets come from the committed public/reveal/. node .ai/templates/generate-reveal-zip.mjs # → public/downloads/offon-reveal-template.zip # pptxgenjs is not in devDependencies (not needed in CI). Install it locally first: # npm install pptxgenjs node .ai/templates/generate-pptx.mjs # → public/downloads/offon-deck-template.pptx ``` +There is **no** content generator, `npm run generate`, or `*.generated.ts` — routes and rendered prose come from the content collection at build time. + --- ## Code Quality -- Use explicit return types on all functions and components. -- Prefer named exports for components. -- Keep components small and single-responsibility. -- Functions must have a single responsibility. If a function requires more than one level of conditional nesting to describe in plain language, split it. -- Use functional components with hooks only. No class components. -- Prefer `const` over `let`, never `var`. -- Use async/await over promise chains. Always handle errors explicitly. -- Never leave unused imports, variables, or dead code. -- Write self-documenting code. Add comments only for non-obvious logic. +- Explicit return types on functions and helpers. +- Keep components small and single-responsibility. Split a function that needs more than one level of conditional nesting to describe. +- Prefer `const`; never `var`. Use async/await; handle errors explicitly. +- Never leave unused imports, variables, or dead code. Self-documenting code; comment only non-obvious logic. --- ## Stability Rules - Never remove or rename existing exports without checking all usages first. -- Never change a component's props interface without updating all call sites. +- Never change a component's props without updating all call sites. - Never delete files without confirming they are unused. - When refactoring, change one thing at a time. Do not mix refactors with feature changes. -- Always verify no TypeScript errors after making changes. -- Prefer extending existing components over rewriting them. -- If a change could break something, flag it explicitly before proceeding. +- Always verify the build (`npm run build`) has no TypeScript errors after changes. +- Prefer extending existing components over rewriting them. Flag risky changes before proceeding. --- ## Debugging Rules -When diagnosing a bug, especially in the production build, follow these rules without exception. They exist to prevent debugging by accumulation. - ### Evidence rules -- Never claim a fix worked based on source inspection alone. The only signal that counts is the expected behavior observed in a real browser against the current bundle hash. -- Before acting on any error message, verify the error came from the current build. Compare the bundle hash in the error stack trace (e.g. `index-XXXX.js`) against the latest build output. If they differ, the browser is serving cached code and the error is stale. -- Before acting on any diagnostic output, state what evidence supports the conclusion. "Only X was left in the DOM" is not evidence of what the DOM looked like at error time. React's error recovery can tear down the tree before the diagnostic runs. -- When a grep claims to confirm something, verify the grep pattern is specific enough to exclude false positives. Strings like "hydrateRoot" exist in production React too, so their presence proves nothing about whether the build is minified. +- Never claim a fix worked from source inspection alone. The only signal that counts is the expected behaviour observed in a real browser against the current build (`npm run build && npm run preview`). +- Before acting on any error, verify it came from the current build. Astro emits hashed asset names (`_astro/*.js`); a stale hash means the browser is serving cached code. +- Before acting on diagnostic output, state what evidence supports the conclusion. +- When a grep claims to confirm something, verify the pattern excludes false positives. `::after` and other pseudo-elements are invisible to `querySelectorAll('*')` — layout/overflow bugs can hide there. ### One-fix-at-a-time rule -- Never stack fixes. One change, rebuild, verify in a real browser, then the next. If you apply two fixes before verifying, you cannot tell which one worked or if either did. -- Commit after every verified fix. Each commit should have a clear before/after. -- If the same bug has been "fixed" more than once in a session and still reproduces, stop. The diagnosis is wrong. Go back to first principles. - -### Build cache rules +- Never stack fixes. One change, rebuild, verify, then the next. Commit after every verified fix. +- If the same bug has been "fixed" more than once in a session and still reproduces, stop and go back to first principles. -- Always run `rm -rf dist node_modules/.vite` before any rebuild you intend to verify against. Vite's cache can silently produce stale output. -- After rebuilding, always compare the new bundle hash to the previous one. If the hash is identical, the cache was reused. Clear it and rebuild. +### Server / cache rules -### Getting unminified React errors - -- The `--mode development` flag alone does not produce a dev React build with Vite's React plugin. Proof: a dev React bundle is roughly 1.4 MB; a production bundle is roughly 330 KB. -- To force a dev React build, add to vite.config.ts inside defineConfig: - define: { 'process.env.NODE_ENV': JSON.stringify('development') }, - build: { minify: false, sourcemap: true } -- Verify the dev build actually happened: `ls -lh dist/assets/index-*.js`. Size should be ~1.4 MB, not ~330 KB. -- Revert this change before merging to main. +- Playwright's webServer uses `reuseExistingServer: false`; kill any stray `astro dev`/`astro preview` on port 4321 before running tests (a lingering **dev** server has the dev toolbar, which fails focus-ring tests). Astro 7 runs `astro preview` as a background daemon — if a prior run left one alive, stop it with `astro preview stop` before re-running tests. +- If a build looks stale, `rm -rf dist .astro` and rebuild. --- ## TypeScript -- `noImplicitAny: false` and `strictNullChecks: false` are intentional. Do not change them. -- Avoid `any` in new code. Use proper types or `unknown` with narrowing. -- Never use `@ts-ignore`. -- Use `@/*` path alias for all imports from `src/`: e.g. `import { cn } from "@/lib/utils"`. -- Prefer `type` over `interface` for object shapes. +- Use the `@/*` path alias for imports from `src/`: `import { BRAND_NAME } from "@/lib/site"`. +- Astro components declare props with `interface Props { ... }` and `Astro.props`. In plain `.ts` prefer `type` for object shapes. +- Avoid `any`; use `unknown` with narrowing. Never `@ts-ignore`. `tsconfig.json` extends `astro/tsconfigs/strict`. --- ## Components -- Always check `src/components/ui/` before building a new primitive. -- `src/components/ui/` contains two files: `badge.tsx` and `tooltip.tsx`. Adding a new shadcn component requires an immediate use case in the same PR. Unused components are removed. To add one: `npx shadcn@latest add <component>`. -- Never modify files inside `src/components/ui/` directly. Extend or wrap them in `src/components/`. -- Page-level components go in `src/pages/`. Reusable components go in `src/components/`. -- Extract sub-components into `src/components/` rather than nesting them inline. -- Do not duplicate card or list markup across components. If the same JSX structure appears in two places, extract a shared component. `FilteredLevelCard` is the established pattern. -- **Buttons:** use raw `<button>` elements with the CSS utility classes defined in `src/index.css` (`.btn-primary`, `.btn-ghost`, `.btn-soft`, `.btn-inverse`, `.btn-ghost-inverse`). There is no `Button` component wrapper and no `@radix-ui/react-slot` dependency. See `styleguide.md` for which class to use on which background color. -- **Toasts:** if toast notifications are ever needed, install `sonner` and add `src/components/ui/sonner.tsx` (shadcn pattern). Mount `<Toaster>` in the nearest layout that actually triggers a toast. Do not install speculatively. -- **TooltipProvider** is intentionally not mounted in `Layout.tsx` until a call site exists. Wrap only the subtree that uses `<Tooltip>` with `<TooltipProvider>` at that point. -- **Author-controlled prose fields contain pre-rendered HTML.** Every YAML/TS field that holds prose written by a challenge author (`level.audience`, `tool.description`, `step.title`, `step.content`, `contributor.about`, `rewards.eligibility`, `tier.description`, `rewards.ranking_note`, `level.learnings`, `level.objective`, `level.intro`, `level.backstory`, `level.hook`, `level.scenario`, `level.architecture`, `adventure.story`, `adventure.backstory`) is converted from Markdown to sanitised HTML at build time by `scripts/generate-adventures.mjs`. Always render them with `dangerouslySetInnerHTML={{ __html: value }}` and the `md-inline` (inline prose) or `md-content` (block content) CSS class. Never render as `{value}` directly. Identifier fields (`id`, URLs, enum values like `difficulty`, emoji) are not author prose and are rendered directly. - - **When the container is an interactive element** (e.g. a `<Link>` card or a `<button>`), call `stripLinks(html)` from `src/lib/markdown.ts` before passing to `dangerouslySetInnerHTML` to prevent nested `<a>` inside `<a>` or `<button>`, which is invalid HTML. - - **When placing a prose HTML field in a plain-text context** (e.g. a `<meta content="">` attribute), call `stripHtml(html)` from `src/lib/markdown.ts`. This strips tags *and* decodes HTML entities. Using a bare tag-strip regex leaves entities intact; React then double-encodes them in the attribute value (e.g. `&` → `&amp;`). - - **Exception: `adventure.story` in `AdventureCard` and `summaries.ts`:** The summary card and `ADVENTURE_SUMMARIES` store `story` as plain text (no HTML) so the home page renders it as a plain `<span>` with no markdown overhead. The generator emits a build-time warning if any story value contains markdown syntax (`*`, `_`, `` ` ``). Keep story field values as plain prose. - - **The markdown pipeline (`unified`, `remark-parse`, `remark-gfm`, `remark-rehype`, `rehype-raw`, `rehype-sanitize`, `rehype-stringify`) is dev-only**, used only by `scripts/generate-adventures.mjs`. Do not import any of these packages in component or page files. +- Static UI is a `.astro` component (zero JS shipped). For interactivity, default to a `.astro` component with a plain `<script>`; the site currently ships **zero islands**. Only reach for a **Vue island** when the component has genuinely reactive state that a class toggle and a small script cannot express, and hydrate it with the lightest directive that works: `client:visible` / `client:idle` by default, `client:load` only for above-the-fold interactivity (protects the Lighthouse baseline). +- **Frameworks: Vue, never React.** `@astrojs/vue` and its toolchain stay installed even while unused, so adding an island is a one-file change. Do not strip them as unused dependencies. +- **Listener and subscription lifecycle:** any event listener or store subscription that targets a **long-lived node** (`document`, `window`, or any node that survives ClientRouter swaps) and is registered inside an `astro:page-load` handler must be torn down under `astro:before-swap`. `MobileMenu.astro` is the canonical reference implementation. A module-scope variable holds the teardown function; `astro:before-swap` calls it and nulls the reference. Init must be idempotent. Listeners that accumulate across navigations silently degrade from the second visit onward. + Two patterns are safe without `astro:before-swap` — do not add teardown to these: + 1. **Module-scope delegation on a surviving node** (`ThemeToggle.astro`): `document.addEventListener("click", handler)` at module scope runs exactly once per session. `document` is never swapped; delegation matches at event time, so there is no stale node reference and no accumulation. + 2. **Listeners on the component's own child nodes** (`StarterNudge.astro`): when ClientRouter swaps `<body>`, the component's subtree is detached. Dead listeners on detached nodes can never fire and are GC-eligible. The next `astro:page-load` call operates on fresh nodes. No teardown needed. +- **Inline links in prose need `{" "}` around them.** Astro removes the whitespace between text and an adjacent element when the source has a newline there. `e2e/inline-spacing.spec.ts` guards this. +- `.astro` components cannot be rendered inside a `.vue` island. If an island needs a badge/pill/icon, inline the markup and use `lucide-vue-next`. +- **Buttons:** raw `<button>` with the CSS utility classes in `src/styles/index.css` (`.btn-primary`, `.btn-ghost`, `.btn-soft`, `.btn-inverse`, `.btn-ghost-inverse`). No Button wrapper. See `styleguide.md`. +- **Touch targets (WCAG 2.5.8):** nav/footer links and any blockified interactive element must be ≥24×24px. Nav links use `min-h-[44px]`, footer links `min-h-[48px]`. +- **Author-controlled prose is pre-rendered, sanitised HTML.** The content collection converts author markdown fields (`level.audience`, `tool.description`, `step.title`, `step.content`, `contributor.about`, `rewards.eligibility`, `tier.description`, `rewards.rankingNote`, `level.learnings`, `level.objective`, `level.intro`, `level.backstory`, `level.scenario`, `level.architecture`, `adventure.story`, `adventure.backstory`) to sanitised HTML at build time via `src/lib/markdown-pipeline.mjs`. Render with `set:html={value}` and the `md-inline` (inline) or `md-content` (block) class — via `<InlineProse html={...} />`, which picks the wrapper automatically. Never render `{value}` raw. + - **Inside an interactive element** (a link card or button): call `stripLinks(html)` from `@/lib/markdown` first, to avoid nested `<a>`/`<button>`. + - **Into a plain-text context** (e.g. a meta attribute): call `stripHtml(html)` from `@/lib/markdown` (strips tags and decodes entities). + - `adventure.story` is rendered plain in card views (`stripHtml`) to keep card markup light. + - The markdown packages (`unified`, `remark-*`, `rehype-*`) are used only by `src/lib/markdown-pipeline.mjs` at build time. Do not import them in pages/components. ### Component CSS patterns -- `hero-badge` class on the hero pill `<div>` in `Hero.tsx`. It is used for CSS scoping of light mode overrides. -- `logo-link` class on the Navbar logo `<Link>`. It is used to exclude the logo from nav link hover styles. -- Footer nav group labels ("explore", "community") use `<p>` with `font-sans font-normal text-xs uppercase tracking-widest text-faint`. Do not use heading elements (`<h2>` etc.) here. The nav groups are already identified by `aria-label`, and heading elements create spurious document-outline entries that disrupt screen reader H-key navigation. Source text must be lowercase because these are overline labels styled with `text-transform: uppercase`. -- `data-difficulty` attribute on `DifficultyBadge`. It is used for CSS targeting of badge text color. -- `contributor-pill` class on `ContributorBadge`. Scopes light mode overrides: transparent background with slate border instead of the near-invisible `bg-primary/5`. -- `contributor-pill-glow` class on `ContributorBadge` (applied via `glow` prop). Static amber box-shadow glow, sized for a small pill. Used only on `ChallengeDetail` -- not in `AdventureCard`. -- `docs-ext-link` class on all inline prose links site-wide. Bundles `inline-flex`, `align-items: center`, `gap`, `underline`, `decoration-thickness`, `underline-offset`, `border-radius`, focus-visible ring, and color/hover transitions. Handles both modes: dark mode foreground text with amber underline, hover to full `#ffc034`; light mode near-black text with `currentColor` underline, hover to `--link-hover-light` (`hsl(41 100% 22%)` dark amber, ~7.4:1 contrast). Used in `CommunityGuide`, `DiscussionSection`, `CommunitySection`, `LevelCard`, `PersonNameLink`, `ChallengeBuildersSection`, `ChallengeDetail`, `CommunitySidebar`, `RewardsCard`, `Accessibility`, and `Privacy`. Links inside pre-rendered adventure HTML use the `.md-inline a` and `.md-content a` rules in `src/index.css` instead. Do not use `hover:text-primary` or `hover:underline` on inline links, and do not add redundant `inline-flex items-center gap-*` utilities. Use `docs-ext-link` alone, adding only contextual utilities (font-size, weight, margin). +- `hero-badge` on the Hero pill; `logo-link` on the Navbar logo (excludes it from nav-link hover); `data-difficulty` on `DifficultyBadge`; `contributor-pill` / `contributor-pill-glow` on `ContributorBadge`. +- Footer nav group labels ("explore", "community") use `<p class="font-sans ... text-faint">`, not headings (they'd create spurious document-outline entries). Source text is lowercase (CSS uppercases). +- `docs-ext-link` on all inline prose links site-wide (bundles inline-flex, underline, focus ring, and light/dark colour handling). Links inside pre-rendered adventure HTML use the `.md-inline a` / `.md-content a` rules in `index.css`. Do not add redundant `hover:*`/`inline-flex` utilities. --- -## Data +## Content collection + +Authored as YAML at `src/data/adventures/<id>/adventure.yaml`, loaded and validated by `src/content.config.ts`: + +- **Custom loader** (not `glob()`): reads the YAML with the `yaml` package. Astro's built-in glob YAML parser auto-casts unquoted ISO timestamps to `Date` objects, corrupting `deadline` fields — the `yaml` package (YAML 1.2 core) keeps them as strings. Digest-gated. +- **Zod schema** mirrors the old JSON Schema (`.strict()` = fail on unknown fields). `npm run sync` runs it; invalid YAML fails the build. +- **Markdown fields** are rendered to sanitised HTML in the loader via `mdToInline`/`mdToBlock` (`src/lib/markdown-pipeline.mjs`, which preserves the original abbr-tooltip expansion, external-link annotation, and `rehype-sanitize` posture). `astro:content` returns `entry.data` with HTML fields already rendered. +- **Field normalization** (title/name, story/backstory[0], icon/emoji, difficulty/emoji, learnings aliases, codespacesUrl, discussionUrl, deadline, rewards defaults, meta descriptions, services→step injection) lives in `src/content.config.ts` + `src/lib/adventure-derive.mjs`. +- **Discussion + leaderboard** JSON (`<level>-posts.json`, `leaderboard.json`) is read at build time by `src/lib/community-data.ts` (node `fs`, resolved from `process.cwd()`). These render statically — no client fetch. Refreshed hourly by `refresh-community-data.yml`. +- **Solutions** are pre-built TS objects in `src/data/solutions/<id>/<level>.ts`, loaded via `import.meta.glob` in `src/lib/solutions.ts`. No generation. +- **No runtime `fetch` in components.** All data is resolved at build time. -- 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`, `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`. +Adding an adventure requires only the YAML + per-level `*-posts.json` and registering the id in `ADVENTURE_CATEGORIES` (`scripts/refresh-leaderboard.mjs`). Routes appear automatically via `getStaticPaths()`. --- ## Styling -- Use Tailwind utility classes directly on JSX elements. -- Always check the `@theme` block in `src/index.css` before introducing any new color, font, spacing, or border radius value. Never hardcode these. There is no `tailwind.config.ts`; all theme values live in the `@theme` block in `src/index.css`. -- Both light and dark mode must work. Use the CSS variable pairs (`bg-background`, `text-foreground`) that shadcn sets up. Never hardcode a color that only works in one mode. -- Never add a `dark:` override without a corresponding base (light) style. -- Mobile first. Write base styles for mobile, then add `sm:`, `md:`, `lg:` breakpoints as needed. -- For font utilities, type scale, component class patterns (buttons, pills, badges, overline labels), and animations, see `styleguide.md`. It is the source of truth. Do not duplicate those details here. -- Never write custom CSS unless Tailwind genuinely cannot do the job. If you must, add it to `src/index.css` with a comment explaining why. -- Light mode overrides: do NOT put them inside `@layer base`; rules there are always overridden by `@layer utilities`. Add unlayered rules to the "Light mode overrides" section at the bottom of `src/index.css`, scoped to `.light`. +- Tailwind utilities directly on elements. Check the `@theme` block in `src/styles/index.css` before adding any colour/font/spacing/radius; never hardcode these. +- Both light and dark mode must work. Use the CSS variable pairs (`bg-background`, `text-foreground`). Never add a `dark:` override without a base (light) style. +- Mobile first (`sm:`/`md:`/`lg:`). See `styleguide.md` for the type scale, component classes, and animations (source of truth). +- **Light mode overrides:** add unlayered rules to the "Light mode overrides" section at the bottom of `index.css`, scoped to `.light` (rules in `@layer base` are overridden by `@layer utilities`). ### Design system rules -- Light mode uses `.light` class on `<html>`, set by the `useTheme` hook. -- Yellow `#ffc034` is accent-only in light mode. Never use it as a text color. -- Dark mode uses `:root` and `.dark`. Never modify these when fixing light mode issues. -- Tailwind `group-hover:*` and `group-focus:*` utilities are not matched by `.light .classname` selectors. Always add explicit `.light .group:hover` rules in the unlayered light mode overrides section of `src/index.css`. -- Avatar palette colors must not be used directly as text colors in light mode. They fail contrast on near-white surfaces. Use `hsl(var(--foreground))` as the text color for avatar initials in all modes. +- Light mode uses `.light` on `<html>`, set by the inline pre-paint script in `Layout.astro` and by `ThemeToggle.astro`'s delegated click handler (localStorage key `theme`). `ThemeToggle` is static markup: CSS picks the icon and the sr-only accessible name off the `.dark` class, so it is correct before any JS runs. +- Yellow `#ffc034` is accent-only in light mode; never a text colour. +- Dark mode uses `:root`/`.dark`. Never modify these when fixing light mode. +- `group-hover:*`/`group-focus:*` are not matched by `.light .classname`; add explicit `.light .group:hover` rules. --- ## Accessibility -Read [`ACCESSIBILITY.md`](ACCESSIBILITY.md) before writing or modifying any component. It contains the full contributor checklist, WCAG principle reference, and manual testing requirements. +Read [`ACCESSIBILITY.md`](ACCESSIBILITY.md) before writing or modifying any component. WCAG 2.2 AA is the floor, not the goal. -The target is not minimum compliance. Every component must be genuinely usable by keyboard-only users, screen reader users, and people with low vision. WCAG 2.2 AA is the floor, not the goal. +The `e2e/a11y.spec.ts` suite gates every representative route on axe (dark, light, and forced-colors with the full WCAG tag set), touch-target size, focus-ring visibility (dark + light), focus traps, and 200% zoom reflow. Never reduce the axe tag set `["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22aa","best-practice"]`. Add new routes to `PAGES` in `a11y.spec.ts` and `smoke.spec.ts`. --- ## Analytics and Consent -The site uses Google Analytics 4 with **Consent Mode v2 in gated-load mode**. No data of any kind is sent to Google until the user clicks Accept on the cookie banner; the `gtag.js` script itself is not loaded until that point. Cross-domain measurement between `offon.dev` and `community.offon.dev` is configured in the GA4 admin UI, not in this codebase. +Google Analytics 4 with **Consent Mode v2 in gated-load mode**: no data of any kind is sent to Google until Accept; `gtag.js` is not loaded until then. After Accept, only `analytics_storage` flips to `granted`; the three ad signals stay denied for the site's lifetime. -After Accept, only `analytics_storage` flips to `granted`. The three ad signals (`ad_storage`, `ad_user_data`, `ad_personalization`) stay denied for the lifetime of the site since OffOn does not run Google Ads. +### Where it lives -### Constants +- **`Layout.astro`** contains the minimal inline `<head>` bootstrap (`is:inline`): bootstrap `window.dataLayer`, define `window.gtag` as the `dataLayer.push` shim, and `gtag('consent','default',{...})` with all four signals denied. **No** `wait_for_update`, localStorage read, `js`, `config`, or `<script src=...googletagmanager...>`. +- **`src/stores/consent.ts`** owns the state (a plain nanostore `$consent`, default `null`, so island SSR matches hydration) and the `gtag.js` injector. The injector is shared by Accept and the mount-restore path, gated by a module-scoped `gtagScriptInjected` boolean. On Accept it pushes `consent update` + `js` + `config` synchronously **before** appending the script tag. `config` passes only `cookie_flags: 'SameSite=Lax;Secure'`, `cookie_expires: 15552000`, `send_page_view: false`. The stored format (`{value, timestamp}` + 180-day expiry, key `analytics_consent`) is preserved from the React app. +- **`src/components/ConsentBanner.astro`** is static markup plus one script: both states are rendered `hidden` and the script reveals whichever matches `$consent`. Keeps `aria-live="polite" aria-atomic="true"`. It calls `initConsent()` (GPC + restore) and moves focus only from the click handlers, never from the subscription. `firePageView` and `trackClicks` live in their own script in `Layout.astro`, independent of this component. +- **`firePageView`** only fires when `$consent === "granted"` and `gtag` is loaded — never queue events while undecided/denied. -All analytics-related constants live in `src/data/constants.ts`: +### Consent state machine (enumerate all transitions before touching this code) -| Constant | Purpose | -| --- | --- | -| `GA_MEASUREMENT_ID` | GA4 Measurement ID. Used by `useConsent.tsx` only, when it injects `gtag.js` on Accept. The inline bootstrap in `src/root.tsx` does not reference it. | -| `CONSENT_STORAGE_KEY` | `localStorage` key for the consent decision (`analytics_consent`). | -| `CONSENT_EXPIRY_MS` | Stored consent expiry (180 days). Re-prompt the user on the next visit after this. | -| `BRAND_NAME` | Always `"OffOn"`. Never hardcode the string. | -| `BRAND_SHORT_DESCRIPTION` | One-sentence brand description. Used in `Footer.tsx` and meta descriptions. Never hardcode. | -| `BRAND_SLOGAN_PARTS` | Tuple of the three slogan parts: `["Vendor-Neutral", "Open Source", "Community-Driven"]`. | -| `BRAND_SLOGAN` | Full slogan parts joined with `". "`. | -| `BRAND_SECONDARY_LINE_PARTS` | Tuple of the three tagline parts: `["always On.", "always Open.", "always Learning."]`. The `Hero.tsx` heading renders only the first two (`[0]` and `[1]`); `[2]` ("always Learning.") is not part of the hero. | -| `BRAND_SECONDARY_LINE` | Full tagline (all three parts) joined with spaces. Used for the complete brand line in `BottomCTA.tsx` and `BrandGuidelines.tsx`, not the hero. | -| `COMMUNITY_URL` | Real URL of the Discourse instance. Never hardcode. | -| `COMMUNITY_DISPLAY_NAME` | User-facing display name for the community URL. Use for visible text. | -| `CODE_OF_CONDUCT_URL` | Canonical URL of the Code of Conduct topic on Discourse. Use instead of hardcoding `${COMMUNITY_URL}/t/code-of-conduct/31`. | -| `CODESPACES_BASE` | GitHub Codespaces base URL for the challenges repo. Used by `CodespacesButton.tsx`. | -| `CHALLENGES_REPO_URL` | GitHub URL of the challenges repo (`https://github.com/off-on-dev/open-source-challenges`). Use instead of hardcoding. | -| `PROPOSE_ADVENTURE_URL` | Deep link to the adventure ideas section of the challenges repo CONTRIBUTING.md. Use instead of hardcoding. | -| `SITE_URL` | `"https://offon.dev"`. Use for canonical URLs and OG tags. | -| `SITE_NAME` | `"offon.dev"`. | -| `CONTACT_EMAIL` | Contact email address. Used in `CommunityGuide.tsx`. Never hardcode. | -| `LINKEDIN_URL` | LinkedIn company page URL. | -| `BLUESKY_URL` | Bluesky profile URL (`https://bsky.app/profile/off-on-dev.bsky.social`). Used in `Footer.tsx`. | -| `X_URL` | X (Twitter) profile URL (`https://x.com/OffonDev`). Used in `Footer.tsx`. | -| `THEME_STORAGE_KEY` | `localStorage` key for the stored theme preference (`"theme"`). Used by `useTheme.tsx`. | -| `CURRENT_YEAR` | Current calendar year (e.g. `2026`). Update manually each January in `src/data/constants.ts`. | -| `OG_IMAGE_ALT` | Fixed alt text for the `og:image` and `twitter:image` tags. A description of the brand card image (`public/og.png`). Used internally by `buildPageMeta`; callers do not pass it. Never derive this from the page title — the OG image is a fixed brand card, not page-specific art. | - -### How it works - -- `src/root.tsx` contains a minimal inline `<head>` bootstrap that does only three things: bootstrap `window.dataLayer`, define `window.gtag` as the `dataLayer.push` shim, and call `gtag('consent', 'default', {...})` with all four signals denied. **No `wait_for_update`. No localStorage read. No `gtag('js', ...)`. No `gtag('config', ...)`. No `<script src="...googletagmanager...">` tag.** -- `src/hooks/useConsent.tsx` owns the React-side state and the `gtag.js` injector. The injector is shared by both the Accept click path and the mount-restore path, gated by a module-scoped `gtagScriptInjected` boolean so the script tag is appended at most once per session. On Accept, the injector pushes `consent update`, `js`, and `config` into `dataLayer` synchronously **before** appending the script tag, so when `gtag.js` loads it drains the queue in the correct order. The `config` call passes only `cookie_flags: 'SameSite=Lax;Secure'`, `cookie_expires: 15552000` (180 days), and `send_page_view: false`. No `cookie_domain` or `linker`. -- On Decline, the hook pushes `consent update analytics_storage: denied` and clears any `_ga*` cookies. The script tag is **not** removed; `dataLayer` is **not** wiped; `window.gtag` is **not** replaced. `gtag.js` itself stops sending hits when consent is denied. -- On Reset (floating cookie button): same as Decline plus state goes back to `null` and `localStorage` is cleared so the banner reappears. -- `src/components/ConsentBanner.tsx` renders a fixed bottom bar until the user makes a choice. Once consent is set, it renders a floating cookie icon button (bottom-right) that calls `reset()` to reopen the banner. The banner's root `<div>` must keep `aria-live="polite"` so screen readers in virtual cursor mode announce it when it appears after JS hydration. Do not remove it. -- `src/Layout.tsx` mounts `PageViewTracker` and `ClickTracker`. **Both gate on `consent === "granted"`.** Pushing events to `dataLayer` while `gtag.js` is not loaded would queue them, and a later Accept would drain the queue and retroactively send pageviews and click events for routes/clicks the user made while consent was undecided or denied. Gating prevents that. -- `src/hooks/useTheme.tsx` manages the light/dark toggle. Theme is stored in `localStorage` under key `theme`. `ThemeProvider` is mounted in `Layout.tsx`. - -### Consent state machine: enumerate all transitions before touching this code - -| From | To | Trigger | localStorage | React state | gtag.js | dataLayer / cookies | +| From | To | Trigger | localStorage | $consent | gtag.js | dataLayer / cookies | | --- | --- | --- | --- | --- | --- | --- | -| `null` | `"granted"` | User clicks Accept | write `granted` | `setConsent("granted")` | injected if not already | push `consent update granted` + `js` + `config` | -| `null` | `"denied"` | User clicks Decline | write `denied` | `setConsent("denied")` | not injected | push `consent update denied`, clear `_ga*` cookies (no-op if none) | -| `"granted"` | `"denied"` | Decline after grant | write `denied` | `setConsent("denied")` | unchanged (still loaded) | push `consent update denied`, clear `_ga*` cookies | -| `"denied"` | `"granted"` | Accept after decline | write `granted` | `setConsent("granted")` | injected if not already | push `consent update granted` (+ `js` + `config` only if first injection) | -| `"granted"` | `null` | User clicks Cookie Preferences | clear | `setConsent(null)` | unchanged | push `consent update denied`, clear `_ga*` cookies | -| `"denied"` | `null` | User clicks Cookie Preferences | clear | `setConsent(null)` | unchanged | push `consent update denied` | -| `null` | `"granted"` | Page load with stored `granted` | (read) | `setConsent("granted")` | injected by mount effect | push `consent update granted` + `js` + `config` | -| `null` | `"denied"` | Page load with stored `denied` | (read) | `setConsent("denied")` | not injected | nothing | -| `null` | `"denied"` | Page load, GPC active, no stored preference | write `denied` | `setConsent("denied")` | not injected | clear `_ga*` cookies | -| `"denied"` | `"denied"` | Page load, GPC active, stored `denied` | overwrite `denied` | `setConsent("denied")` | not injected | clear `_ga*` cookies | -| GPC active | `"granted"` | Page load, GPC active, stored `granted` | (read) | `setConsent("granted")` | injected by mount effect | push `consent update granted` + `js` + `config` | +| `null` | `granted` | Accept | write `granted` | `granted` | inject if not already | `consent update granted` + `js` + `config` | +| `null` | `denied` | Decline | write `denied` | `denied` | not injected | `consent update denied`, clear `_ga*` | +| `granted` | `denied` | Decline after grant | write `denied` | `denied` | unchanged | `consent update denied`, clear `_ga*` | +| `denied` | `granted` | Accept after decline | write `granted` | `granted` | inject if not already | `consent update granted` (+ js/config only on first injection) | +| `granted`/`denied` | `null` | Cookie Preferences (reset) | clear | `null` | unchanged | `consent update denied`, clear `_ga*` | +| `null` | stored value | Page load with stored choice | (read) | stored | inject if stored `granted` | on granted: `consent update granted` + js + config | +| `null`/`denied` | `denied` | Page load, GPC active, not explicitly granted | write `denied` | `denied` | not injected | clear `_ga*` | +| GPC active | `granted` | Page load, GPC active, stored `granted` | (read) | `granted` | injected | `consent update granted` + js + config | ### Do not -- Do not load `gtag.js` outside the consent injector. -- Do not put `gtag('js')` or `gtag('config')` in `root.tsx`. Both belong in the injector, queued after the consent update. -- Do not reintroduce `wait_for_update`. -- Do not remove GPC detection: `navigator.globalPrivacyControl === true` is checked on mount in `useConsent.tsx`. If active and no explicit prior Accept is stored, consent is auto-denied without prompting the user. -- Do not reintroduce `ANALYTICS_LINKER_DOMAINS` or `cookie_domain`. -- Do not put the consent update inside `script.onload`. It must be queued before `appendChild` so the dataLayer drains in the correct order. -- Do not remove the script tag, wipe `dataLayer`, or replace `window.gtag` on deny. -- Do not push `page_view` or `click_event` when consent is not granted. -- Do not skip clearing `_ga*` cookies on deny or reset. - ---- - -## Testing - -- Use Vitest for all unit and integration tests. -- Use `@testing-library/react` for component tests. Test from the user's perspective, not implementation details. -- Test files live in `src/test/` or co-located alongside the module as `*.test.ts(x)`. -- Write tests for all logic in `src/lib/` and `src/hooks/`. Target 80% coverage for new utility and hook files. -- Pure visual components (no state, no side effects) do not require tests. A visual component that holds state or has side effects is not a pure visual component and must have tests. -- Prefer `getByRole` and `getByLabelText` queries over `getByTestId`. They also validate accessibility. -- Never ship code that causes test or lint failures. -- Every new hook, utility function, or stateful component must have tests covering the happy path, edge cases, and all state transitions. -- Tests must be written as part of the implementation, not as an afterthought. -- If a component or hook has side effects (DOM mutations, localStorage, external scripts), mock those side effects in tests and assert they are called correctly. -- When fixing a bug, add a regression test that would have caught it before writing the fix. -- When fixing a bug caused by an incorrect import, file path, or configuration value, add a regression test that asserts on the file's contents. -- Prerender tests live in `src/test/prerender.test.ts` and require a production build. Always run `npm run build` before `npm test` if prerender tests are included. -- Playwright smoke tests live in `e2e/smoke.spec.ts` and require a production build. The axe audit runs with tags `["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"]` in both dark and light mode. Never remove `wcag22aa` from this list. When adding a new prerendered route, add it 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. -- SEO tests live in `src/test/seo.test.ts` and require a production build. When adding a new prerendered route, add it to the `ROUTES` array in `src/test/seo.test.ts`. -- **Visual regression tests** live in `e2e/visual.spec.ts` and require a production build. Run `npm run build && npm run test:visual`. First run generates baseline screenshots in `e2e/__screenshots__/`; subsequent runs compare against baselines and fail if pixel differences exceed threshold. Baselines are committed. To update baselines after intentional visual changes: `npm run test:visual:update`. When adding a new page or making major layout changes, add it to `VISUAL_ROUTES` in `visual.spec.ts` and regenerate baselines. Use `maskSelectors` to hide dynamic content (timestamps, discussion posts) that changes between builds. **These tests are local-only and are not run in CI** (font rendering differs between macOS and Linux). Run them manually before and after any major design change. Never name a smoke test describe block with "visual" in the title, as `--grep visual` routes tests between the two suites. -- When a page renders multiple navigation landmarks, use `within` from `@testing-library/react` to scope queries to the correct landmark before asserting link destinations. -- **Testing hooks with dynamic imports:** Never use `vi.mock` for a dynamic import called inside a hook. Export a loader type and default loader; tests inject `vi.fn().mockResolvedValue(data)` via an optional argument. See `src/hooks/useDiscussionPosts.ts` for the reference implementation. -- **Coverage:** run `npm run test:coverage` for v8 coverage reports. `@vitest/coverage-v8` is installed as a dev dependency. -- **Axe incomplete flags:** When axe reports an "Incomplete" or "Needs Review" result, provide a definitive manual ruling (confirmed violation, confirmed pass, or cannot determine without AT testing) before merging. Do not leave incomplete flags unresolved. Use `/a11y-audit` to evaluate in context. +- Do not load `gtag.js` outside the injector. Do not put `js`/`config` in `Layout.astro` (they belong queued after the consent update in the injector). +- Do not reintroduce `wait_for_update`, `ANALYTICS_LINKER_DOMAINS`, or `cookie_domain`. +- Do not put the consent update inside `script.onload` (queue it before `appendChild`). +- Do not remove GPC detection (`navigator.globalPrivacyControl === true`). +- Do not remove the script, wipe `dataLayer`, or replace `window.gtag` on deny. +- Do not push `page_view`/`click_event` when consent is not granted. Do not skip clearing `_ga*` on deny/reset. --- -## Hydration and Prerender Safety - -Whether or not the site is prerendered today, these patterns cause bugs. They produce visible flashes in client-only apps and break hydration entirely if the site is ever prerendered. Never introduce them. - -### Do not read browser-only globals during render - -- Never read `window`, `document`, `navigator`, `localStorage`, or `sessionStorage` in a component function body. -- Never read them in a `useState` lazy initializer. -- Correct pattern: initialize state with a safe default, then update it in `useEffect` or `useLayoutEffect`. - -### Do not use non-deterministic values during render - -- Never call `Math.random()`, `Date.now()`, `new Date()`, `crypto.randomUUID()`, or `performance.now()` in a render body. -- `new Date().getFullYear()` in JSX is a common mistake. Use a module-level constant instead. - -### Client-only behavior must be gated - -- Anything that depends on `localStorage`, `matchMedia`, or similar must produce the same initial render as a fresh visitor with no stored state. -- For theme and consent state: always render the default (dark, no-consent) on first render, then update in an effect. -- Always wrap `localStorage` reads and writes in `try/catch`. Storage throws in private browsing and when quota is exceeded. - -### No IntersectionObserver or ResizeObserver at render time +## Islands & Hydration Safety -- Always create observers inside `useEffect`, never at the top level of a component or module. -- Guard any observer that affects rendered content with a `typeof window !== 'undefined'` check. -- Use `useIsomorphicLayoutEffect` instead of `useLayoutEffect` in any component that renders during SSG. +These patterns produce hydration mismatches and console errors. Never introduce them. -### entry.server.tsx must use renderToPipeableStream, not renderToString - -- `renderToString` emits `<!--$!-->` markers for any Suspense boundary that suspends during prerender. -- `entry.server.tsx` must always use `renderToPipeableStream` with `onAllReady` callback. -- Never revert to `renderToString` in `entry.server.tsx`. - -### Do not add Suspense wrappers around Outlet in Layout.tsx - -- Adding `<Suspense>` around `<Outlet />` in Layout.tsx creates an extra boundary React Router does not resolve during prerender, producing broken hydration. -- If you need loading states for routes, configure them in the route module itself. - -### useSearchParams() and prerender hydration - -`useSearchParams()` is safe to call during render, but its value differs between prerender (empty, no URL) and client hydration (real URL params from the browser). Deriving initial `useState` from it causes a mismatch: the prerendered HTML has one value, the hydrating client has another, React throws. Always default to the server-safe value (`false`, `null`, or `[]`) and sync to the real param value in `useEffect`. - -```tsx -// WRONG: lazy initializer reads params at prerender time (always empty) and at -// hydration time (real URL), causing a mismatch. -const [hasFiltered, setHasFiltered] = useState(() => searchParams.has("topics")); - -// CORRECT: start with the server-safe default; sync after mount. -const [hasFiltered, setHasFiltered] = useState(false); -useEffect(() => { if (searchParams.has("topics")) setHasFiltered(true); }, []); // eslint-disable-line react-hooks/exhaustive-deps -``` - -### Stale prerendered data - -Loader functions run at build time. The static `.data` file they produce is frozen until the next build. Any data that depends on the current time (e.g. a deadline that has since passed) will be stale when the page loads in the browser. - -**Pattern:** initialize `useState` from the loader value (correct for hydration — prerendered HTML and first client render agree), then correct in a `useEffect` on mount: - -```tsx -const { myField: initialMyField } = useLoaderData(); -const [myField, setMyField] = useState(initialMyField); - -useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setMyField(recomputeFromCurrentTime()); -}, []); // eslint-disable-line react-hooks/exhaustive-deps -``` - -The `set-state-in-effect` disable is intentional: the mount effect corrects a known staleness in the prerendered value, not a derivation that should live in the render body. Do not suppress the rule for other patterns. - -### JavaScript degradation testing - -Core content must be readable with JavaScript disabled. To verify: DevTools → Cmd+Shift+P → "Disable JavaScript" → reload the page. - -- Page headings, body text, images, and navigation links must be visible and functional. -- Filters, theme toggle, and consent banner may degrade gracefully — they are JS-enhanced features. -- Challenge and adventure text, navigation, and all other primary page content must not be exclusively client-side rendered. -- Run `npm run build` and confirm all content appears in the prerendered HTML files in `dist/client/`. +- **An island's first client render must match its SSR output.** SSR runs with default state (`null` consent). Read `localStorage`/`navigator`/the DOM in `onMounted`, then update reactive state — never in `<script setup>` top level or as a `ref` initializer. `$consent` is a plain atom (default `null`); once `@nanostores/vue` is installed, it is safe to read via `useStore` since server and first-client render agree. Theme is not an island at all: `ThemeToggle.astro` renders both states and lets CSS choose off the `<html>` class, which sidesteps the mismatch rather than working around it. +- **No non-deterministic values in a render body.** Build-time `.astro` frontmatter may use `new Date()` (it runs on the server); Vue island templates must not. +- **`client:only` + ClientRouter** has a first-navigation hydration bug — prefer SSR islands (`client:visible`/`idle`/`load`). The global chrome (theme toggle, mobile menu, consent, starter nudge) is plain `.astro` plus scripts, so nothing in it needs `transition:persist`; scripts bind on `astro:page-load` or delegate from `document`. +- **After each client navigation** (`astro:after-swap`), `Layout.astro` re-asserts the `<html>` theme class (prevents flash) and moves focus to `#main-content`. Astro's `<ClientRouter />` provides the route announcer and respects `prefers-reduced-motion`. +- **Wide content** (code blocks) must scroll inside its own `overflow-x:auto` container; grid tracks holding it need `minmax(0,1fr)`, not `1fr`. +- **Progressive enhancement:** core content (headings, prose, nav, cards) must render server-side and work with JS disabled. Filters/theme/consent may degrade. Verify: DevTools → Disable JavaScript → reload; and inspect `dist/`. --- ## SEO -This is a fully static React site. Apply these practices on every page. - -### Document structure - -- Every page must have a unique, descriptive `<title>` tag. -- Every page must have a `<meta name="description">` under 160 characters. -- Add Open Graph tags to every page: `og:title`, `og:description`, `og:url`, `og:type`, and `og:image` where an image is available. -- Add Twitter meta tags: always include `twitter:card` (use `summary_large_image` for pages with images), `twitter:title`, `twitter:description`, and `twitter:image`. -- Use React Router v8's `meta()` export on each route module to manage head tags per page. Use the `buildPageMeta` helper from `src/lib/meta.ts`. - -### Heading hierarchy - -- One `<h1>` per page that clearly describes the page topic. -- Headings follow a logical order with no skipped levels. -- For multi-line hero or section headings, do not use `<br />` inside `h1`/`h2`. Use block-level `<span>` elements for visual line breaks. - -### Links and navigation +Static site. Apply on every page. -- Internal links use React Router `<Link>`. Never trigger full page reloads. -- Use descriptive link text. Never use "click here" or "read more" alone. -- Set the canonical URL for each page as `${SITE_URL}${pathname}` using the `SITE_URL` constant from `src/data/constants.ts`. - -### Performance - -Read [`PERFORMANCE.md`](PERFORMANCE.md) before adding any new dependency, font, image, or route. - -### Global head setup (root.tsx) - -- **Required `<head>` elements** -- verify these are present whenever editing `src/root.tsx`: - - `<meta charset="utf-8">` -- must appear in the first 1024 bytes of the HTML, before any non-ASCII content. - - `<meta name="viewport" content="width=device-width, initial-scale=1">` -- tells mobile browsers to render at device width. Never set `user-scalable=no` or `maximum-scale=1`; disabling user zoom breaks WCAG 1.4.4 (Resize Text). - - `<meta name="color-scheme" content="dark light">` -- prevents the white flash dark-mode users see before CSS loads, and lets the browser style scrollbars and native form controls to match the active scheme. -- **Favicons** -- the following files must be present in `public/` and linked from `src/root.tsx`: - - `favicon.svg` -- primary favicon; linked as `<link rel="icon" href="/favicon.svg" type="image/svg+xml">`. - - `favicon.png` -- PNG fallback; linked as `<link rel="icon" href="/favicon.png" type="image/png">`. -- The Organization JSON-LD `"logo"` field in `src/root.tsx` uses `https://offon.dev/brand/offon-logo-dark-color.png` (the full brand logo, not the favicon). Do not revert it to `favicon.png`. - - `favicon.ico` -- ICO fallback for older browsers and the Windows taskbar. Place at `public/favicon.ico` (browsers request it automatically). - - `apple-touch-icon.png` -- 180x180 px PNG; linked as `<link rel="apple-touch-icon" href="/apple-touch-icon.png">`. - - A maskable icon entry in `site.webmanifest` with `"purpose": "maskable"` for Android home screens. - - Verify all five are present before shipping any favicon change. -- Add `<link rel="manifest" href="/site.webmanifest" />` to link the web app manifest. -- Add `<meta name="theme-color">` tags for dark and light mode. -- Add JSON-LD structured data as two `<script type="application/ld+json">` blocks: one `@type: "WebSite"` and one `@type: "Organization"`. The `"OffOn"` brand name is hardcoded as a string literal in both (they cannot reference TypeScript constants inside `dangerouslySetInnerHTML`). Update them manually if the brand name ever changes. -- Always include `og:image:width`, `og:image:height`, and `og:image:alt` for all OG image tags. -- Add `og:site_name` and `og:locale` (en_GB) to all global OG tags in `src/root.tsx`. -- Do not add page-specific meta tags to `src/root.tsx`. These must live in each route module's `meta()` export only. - -### URL structure - -- Keep URLs lowercase, hyphen-separated, and descriptive. Never use underscores or camelCase in URL segments. -- Treat published URLs as a public contract. Once a URL is live, it must keep working. If a URL must change, add a redirect route in `src/routes.ts` pointing the old path to the new one. -- Redirect routes in `src/pages/redirects/` use React Router's `redirect()`. Prefer client-side redirects over broken links. Never chain more than one redirect for the same URL. - -### Soft 404s - -- Every path that does not correspond to a real page must return HTTP 404, not 200. GitHub Pages serves `404.html` automatically for unmatched paths -- no configuration is needed. -- Never create a catch-all route that renders a "page not found" UI with a 200 status. Search engines and AI crawlers treat a 200 response as indexable content. -- When retiring a URL, add a redirect route to its successor. If there is no successor, redirect to the nearest parent or category page. Reserve 404 for paths that were never valid. +- Every page: unique descriptive `<title>`, `<meta name="description">` under 160 chars, and canonical `${SITE_URL}${path}` (trailing slash). One `<h1>`; logical heading order (no skips; use block `<span>` for multi-line headings, not `<br>`). +- **Per-page meta comes from the `<SEO>` component** (`src/components/SEO.astro`), fed by `Layout.astro` props (`title`, `description`, `path`, `ogType`, `noindex`). It emits canonical, OG (`og:title/description/type/url/image` + width/height/alt, `og:site_name`, `og:locale` en_GB) and Twitter tags. Do not hand-write these in pages. Legal pages pass `noindex`. +- Internal links use plain `<a href>` with **trailing slashes** and `import.meta.env.BASE_URL` (so PR previews under `/pr-preview/pr-N/` resolve). External links: `target="_blank" rel="noopener noreferrer" aria-describedby="new-tab-hint"`. +- **`Layout.astro` global head** (verify when editing): `<meta charset>` in the first 1024 bytes, viewport (never `user-scalable=no`), `color-scheme`, favicons (svg/png/ico/apple-touch), manifest, both `theme-color` tags, CSP meta, the two JSON-LD blocks (`WebSite` + `Organization`, brand name hardcoded), font preloads, and the PR-preview `noindex` guard. `lang="en"` on `<html>`. +- **Soft 404s:** unmatched paths must return 404, not 200. `src/pages/404.astro` → `dist/404.html` (GitHub Pages serves it). No catch-all route rendering a 200 "not found" page. Retire URLs via the `redirects` map in `astro.config.mjs`. +- Read [`PERFORMANCE.md`](PERFORMANCE.md) before adding a dependency, font, image, or route. --- @@ -555,66 +320,40 @@ Read [`PERFORMANCE.md`](PERFORMANCE.md) before adding any new dependency, font, ### Brand Name -- The brand is always written **OffOn** (camelCase). Never "offon", "Offon", or "OFFON". -- The community was previously known as "Open Ecosystem". That name is retired. Never use it anywhere. -- In code, always use the `BRAND_NAME` constant from `src/data/constants.ts` instead of hardcoding the string. -- As a URL or href: always `offon.dev` (lowercase, e.g. `<a href="https://offon.dev">`). -- As a display name in prose or UI: `OffOn.dev` is the correct form (brand caps, TLD lowercase). Never capitalise the TLD: `OffOn.Dev` is wrong. +- Always **OffOn** (camelCase). Never "offon", "Offon", or "OFFON". +- "Open Ecosystem" is retired. Never use it. +- In code, use the `BRAND_NAME` constant from `src/lib/site.ts`. +- As a URL/href: `offon.dev` (lowercase). As a display name: `OffOn.dev` (never `OffOn.Dev`). ### Tone -- Direct, positive, and community-focused. -- Write for open source enthusiasts, not a corporate audience. -- Use plain language. Avoid jargon unless it is standard in open source contexts. -- Avoid passive voice where an active one works. -- Keep sentences short and scannable. -- Never enumerate specific difficulty levels (e.g. "Beginner, Intermediate, or Expert") in UI copy. Adventures can have one, two, or three levels at any combination of difficulties. Use broad language instead: "the difficulty that fits where you are", "any difficulty level", or similar. +- Direct, positive, community-focused. Write for open source enthusiasts, not a corporate audience. Plain language. Active voice. Short, scannable sentences. +- Never enumerate specific difficulty levels in UI copy. Use broad language ("any difficulty level", "the difficulty that fits where you are"). ### Capitalisation -All UI labels use **title case (Chicago style)**. Body copy uses **sentence case**. - -**Title case applies to:** button and CTA labels, section headings (h2/h3), card and value titles, navigation labels and footer links, pill and badge text. +UI labels use **title case (Chicago)**; body copy uses **sentence case**. -**Title case rule:** capitalise every word except articles (a, an, the), prepositions shorter than five letters, and coordinating conjunctions (and, but, or, nor), unless they open or close the label. - -**Sentence case applies to:** body paragraphs, meta descriptions, `<p>` elements, hero sub-headings, and card descriptions. - -**Exception:** decorative overline labels use CSS `text-transform: uppercase`, so write their source text in plain lowercase. +- **Title case:** button/CTA labels, section headings (h2/h3), card/value titles, nav and footer links, pill/badge text. Capitalise every word except articles, prepositions under five letters, and coordinating conjunctions — unless they open or close the label. +- **Sentence case:** body paragraphs, meta descriptions, `<p>` text, hero sub-headings, card descriptions. +- **Exception:** overline labels use CSS `text-transform: uppercase`, so write source text lowercase. ### Formatting -- Never use em dashes anywhere, including comments and documentation. Use commas, periods, or restructure the sentence instead. -- Maintain a cohesive tone across all pages and components. -- Do not mix formal and casual registers within the same page. - -### External URLs - -- `LINKEDIN_URL` in `src/data/constants.ts` contains the current LinkedIn company page URL. Update it when the LinkedIn company page URL changes. +- Never use em dashes anywhere (comments and docs included). Use commas, periods, or restructure. +- Keep tone cohesive; don't mix formal and casual registers within a page. --- ## Git -- Branch naming: `type/short-description` (e.g. `feat/hero-section`, `fix/nav-scroll`). -- All commits must be signed off: `git commit -s`. -- Never force-push to `main`. -- PR titles follow conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`. - -### Commit types +- Branch naming: `type/short-description` (e.g. `feat/hero-section`). +- All commits signed off: `git commit -s`. +- Never force-push to `main`. PR titles follow conventional commits. -| Type | When to use | +| Type | When | | --- | --- | -| `feat` | New feature | -| `fix` | Bug fix | -| `docs` | Documentation only | -| `style` | CSS or formatting changes | -| `refactor` | Code restructure, no feature or fix | -| `chore` | Maintenance, dependencies | -| `perf` | Performance improvements | -| `security` | Security fixes | -| `config` | Configuration changes | -| `revert` | Reverting a previous commit | +| `feat` / `fix` / `docs` / `style` / `refactor` / `chore` / `perf` / `security` / `config` / `revert` | as named | --- @@ -622,154 +361,77 @@ All UI labels use **title case (Chicago style)**. Body copy uses **sentence case ### Well-known files -- `public/.well-known/security.txt` contains an `Expires` field. Update the date annually (current expiry: `2027-06-01`). An expired security.txt is treated as absent by scanners. -- `public/llms.txt` lists key pages and all live adventures. Update it whenever a new adventure is added (step 7 in the adventure checklist above) or a page is significantly renamed. -- `public/llms-full.txt` is the extended companion to `llms.txt`. It contains full level-by-level detail for every adventure. Update it whenever a new adventure or level is added, or a level's technologies/description changes. -- `public/robots.txt` lists named AI crawler agents. No routine updates needed; add a new agent entry only when a major crawler publishes a new user-agent string. Note: robots.txt does not support inheritance — named `User-agent` groups do not inherit `Disallow` rules from `User-agent: *`. When adding a new path to exclude, repeat the `Disallow` line in every group. -- `public/.well-known/agent-skills/offon/SKILL.md` describes the site to AI agents. Update it if the site's key URLs, adventure list, or technology list changes significantly. After editing it, recompute the SHA256 digest (`shasum -a 256 public/.well-known/agent-skills/offon/SKILL.md`) and update the `digest` field in `public/.well-known/agent-skills/index.json`. A stale digest makes the file unverifiable to compliant agents. -- `public/.well-known/api-catalog` lists machine-readable resources. Update it if a new resource endpoint is added (e.g. a new feed or data file). +- `public/.well-known/security.txt` `Expires` — update annually (current: `2027-06-01`). +- `public/llms.txt` / `llms-full.txt` — update when an adventure/level is added or a page renamed. +- `public/robots.txt` — named `User-agent` groups do not inherit `Disallow` from `*`; repeat `Disallow` in each group. Must include `Sitemap: https://offon.dev/sitemap.xml`. +- `public/.well-known/agent-skills/offon/SKILL.md` — after editing, update the SHA256 `digest` in `index.json`. ### Sitemap -- Every time a new static page is added to `src/pages/` and registered as a route in `src/routes.ts`, its URL must also be added to `public/sitemap.xml` with a `<lastmod>` date. **Exception:** legal/policy pages (`/privacy/`) are intentionally excluded from the sitemap. Do not add them back. -- Dynamic routes with statically known IDs must also be added to `public/sitemap.xml` with a `<lastmod>` date. Adventure and challenge-tag URLs are generated automatically by `scripts/generate-adventures.mjs` and include `<lastmod>` set to the build date; do not add them by hand. -- `robots.txt` at `public/robots.txt` must include: `Sitemap: https://offon.dev/sitemap.xml` -- **Generator region markers:** `scripts/generate-adventures.mjs` uses XML comment markers to patch adventure and tag entries into `public/sitemap.xml` (see `replaceRegion` calls near line 1204 and 1251). The markers are `<!-- GENERATED:adventures -->` / `<!-- /GENERATED:adventures -->` for the adventures block and `<!-- GENERATED:challenge-tags -->` / `<!-- /GENERATED:challenge-tags -->` for the tags block. Do not remove, rename, or reorder these comments. If they are missing, `npm run build` aborts with "Region markers not found". - -### SSG prerendered routes +- `/sitemap.xml` is generated at build time by `src/pages/sitemap.xml.ts` from `getCollection()` + the static route list. Adventure, level, solution, and challenge-tag URLs are automatic. When adding a new **static** page, add its path to the `staticPaths` array in that endpoint (unless it is noindex — `/privacy/` and `/presentation-templates/` are excluded). -- The list of routes React Router v8 prerenders is in the `prerender` array inside `react-router.config.ts`. -- When adding a new static route, add it to **all three** of: `src/routes.ts`, `public/sitemap.xml`, and the `prerender` array in `react-router.config.ts`. +### Routes -When adding a new route to `src/routes.ts`, follow these rules by route type: +- Routes come from file-based pages and `getStaticPaths()`. There is no prerender array to maintain. When adding a page, add it to `PAGES` in `e2e/a11y.spec.ts` and `ROUTES` in `e2e/smoke.spec.ts` (with the expected title), to the `staticPaths` array in `src/pages/sitemap.xml.ts` (except `/privacy/` and `/presentation-templates/`), and to the routes table in `README.md`. -- Static routes: add to `public/sitemap.xml`, the routes table in `README.md`, and the `prerender` array in `react-router.config.ts`. -- Dynamic routes with statically known IDs: add individual URLs to `public/sitemap.xml`, the `prerender` array, and `README.md`. Also create a per-level discussion JSON file if the level has a discussion thread. -- Redirect routes: do not add to `sitemap.xml` or `README.md`. -- Catch-all routes: do not add anywhere. +### Adding an adventure or level -### When adding a new adventure or a new level to an existing adventure - -See [`ADVENTURES.md`](ADVENTURES.md) for the full sync process and PR checklist. The **Sync Adventure** workflow handles routes, sitemap, prerender entries, test arrays, and `public/llms.txt` automatically. The two manual steps before merging are: - -1. Update the routes table in `README.md`. -2. Run `npm run generate`, then commit `public/llms.txt` to the PR branch (the sync workflow modifies it but does not stage it). +See [`ADVENTURES.md`](ADVENTURES.md). In brief: add/extend the YAML at `src/data/adventures/<id>/adventure.yaml`, add each level's `*-posts.json`, register the id in `ADVENTURE_CATEGORIES` (`scripts/refresh-leaderboard.mjs`), and add the new URLs to the test route lists, `README.md`, and `public/llms.txt`. Adventure/level/solution URLs are auto-derived in `src/pages/sitemap.xml.ts` from `getCollection()` — no manual sitemap edit needed. Routes generate automatically. --- ## Deployment -- Push to `main` triggers `deploy.yml` and deploys to GitHub Pages. -- Open PRs trigger `preview.yml` and create a PR preview deployment. -- 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 `/`). Never change this without verifying GitHub Pages routing. - -### Trailing slashes and `_.data` aliases - -GitHub Pages normalises every URL to a trailing slash (e.g. `/adventures/lex-imperfecta` becomes `/adventures/lex-imperfecta/`). All internal `Link to` props use trailing slashes to stay consistent with the URL the browser shows. - -React Router v8 defaults to `trailingSlashAwareDataRequests`. When the current URL has a trailing slash, single-fetch data requests use `<path>/_.data` instead of `<path>.data`. The prerender only generates `<path>.data` files, so a `_.data` request would 404. - -The `postbuild` script (`scripts/create-data-aliases.mjs`) runs automatically after every `npm run build`. It copies each `*.data` file to `<name>/_.data` so both URL formats resolve. Example: - -```text -dist/client/adventures/lex-imperfecta.data # non-trailing-slash request -dist/client/adventures/lex-imperfecta/_.data # trailing-slash request (GitHub Pages) -``` - -`serve.json` at the repo root sets `trailingSlash: true` so `npm run preview` mirrors GitHub Pages behaviour locally. It is not in `public/` and is not served in production. - -**Never remove trailing slashes from `Link to` props.** That would make client-side navigation inconsistent with the URL GitHub Pages shows in the browser. - -### PR preview static assets - -The `preview.yml` copy step explicitly lists every static asset directory and root-level file type that needs to appear in the PR preview. Vite copies `public/` to `dist/client/` during the build, but `preview.yml` then copies those files into the `dist/client/pr-preview/pr-N/` subdirectory that `rossjrw/pr-preview-action` deploys. - -**When adding a new directory or root-level file type to `public/`, you must also add a corresponding copy line in the copy step of `.github/workflows/preview.yml`.** If you forget, the file will exist in production but return 404 in all PR previews. - -Current copy step covers: `assets/`, `fonts/`, `reveal/`, `team/`, `speakers/`, `brand/`, `solutions/`, `downloads/`, `qr/`, `screenshots/`, `deck/`, `deck-template/`, and root-level `*.svg`, `*.png`, `*.ico`, `*.webmanifest`, `*.webp` files. `serve.json` lives at the repo root, not in `public/`, and is not copied here. +- Push to `main` triggers `deploy.yml` → GitHub Pages. Open PRs trigger `preview.yml`. +- The build outputs `dist/`; `JamesIves/github-pages-deploy-action` publishes it to `gh-pages`. Astro emits `dist/404.html` natively. +- `trailingSlash: 'always'` matches GitHub Pages URL normalization (no `_.data` alias handling needed). +- **PR previews** build with `VITE_BASE_PATH=/pr-preview/pr-N/` (→ Astro `base`); the whole `dist/` is the preview source (public assets are copied into `dist/` automatically, so there is no per-directory copy step). `Layout.astro` marks `/pr-preview/` builds `noindex`. ### 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` | any tag | -| `actions/cache` | any (GitHub-created, covered by org checkbox) | -| `actions/setup-node` | any tag | -| `actions/create-github-app-token` | any tag | -| `JamesIves/github-pages-deploy-action` | any tag | -| `marocchino/sticky-pull-request-comment` | any tag | -| `rossjrw/pr-preview-action` | any tag | -| `fsfe/reuse-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) or native shell commands. +The `off-on-dev` org restricts third-party actions. Permitted: `actions/checkout`, `actions/cache`, `actions/setup-node`, `actions/create-github-app-token`, `JamesIves/github-pages-deploy-action`, `marocchino/sticky-pull-request-comment`, `rossjrw/pr-preview-action`, `fsfe/reuse-action`, actions owned by `off-on-dev`, actions created by GitHub, and Marketplace-verified actions. **The official `withastro/action` and `actions/deploy-pages` are NOT allowlisted** — keep the JamesIves deploy flow. Before adding a `uses:`, verify it is permitted or use `gh`/shell. --- ## Before Submitting Code -Every code change must pass all of these checks before being considered done. State the result of each check explicitly before finishing a task. - -### Mandatory checks +State the result of each check explicitly before finishing. -1. **Run lint:** `npm run lint` must exit with zero errors. -2. **Run REUSE lint:** `npm run lint:reuse` must pass. Requires `pip install reuse` once. Run whenever a new file type or extension is added to the repo. -3. **Run tests:** `npm test` must pass with zero failures. -4. **Run e2e and a11y tests:** `npm run build && npm run test:e2e` must pass with zero failures. The axe audit runs tags `["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"]` in both light and dark mode. Never reduce this tag set. Axe catches roughly 30–40% of real issues — treat it as ground truth for mechanical violations, but manual persona testing (see ACCESSIBILITY.md) is always required. -5. **Run build:** `npm run build` must complete with no TypeScript errors or bundling failures. -6. **Re-read every file you changed:** verify the final state is correct. Never assume an edit landed correctly without checking. -7. **Check all call sites:** if you changed a function signature, component props, or exported type, search for all usages and confirm they are updated. -8. **Check imports:** every import must resolve. No unused imports. No circular dependencies introduced. -9. **Verify at three viewports:** 375px, 768px, and 1280px. Always test against the production build, never the dev server. -10. **Check discussion data on every PR:** if the PR adds or modifies adventure levels, verify that a per-level discussion JSON file exists with the correct `discussionUrl`. +1. **Content gate:** `npm run sync` passes (Zod schema over adventure YAML). +2. **Types:** `npm run check` (`astro check`) passes with zero errors. Gated in CI. +3. **Lint:** `npm run lint` passes (ESLint for astro/vue/ts; `typescript` is pinned to 6.x because typescript-eslint does not support TS 7 yet). +4. **REUSE lint:** `npm run lint:reuse` (or `reuse lint`) passes. `.astro`/`.vue` are covered by globs in `REUSE.toml`. +5. **Build:** `npm run build` completes with no errors. +6. **Unit tests:** `npm run test:unit` passes. Tests live in `src/test/` (lib, stores). +7. **e2e + a11y:** `npm run test:e2e` passes. The axe audit runs the full WCAG tag set in dark and light. Kill any stray server on port 4321 first. Manual persona testing (ACCESSIBILITY.md) is still required. +8. **Re-read every file you changed;** verify the final state. +9. **Check call sites** for any changed prop/type/export. **Check imports** resolve; no unused imports. +10. **Verify at 375 / 768 / 1280px** against the production build (`npm run preview`), not the dev server. +11. If the change adds/modifies adventure levels, verify a per-level `*-posts.json` exists. -### Before writing any code +### Red flags — stop and flag to the user -1. Read the relevant files first. Never edit a file you have not read in this session. -2. If the change touches more than one file, list all affected files before starting. -3. If the change involves a state machine, enumerate all transitions first. -4. If the change involves shared state, confirm a context provider is used. -5. If the change involves a side effect (DOM, localStorage, external scripts), write the test before or alongside the implementation. - -### Red flags that require stopping and flagging to the user - -- A fix requires changing more than 3 files you did not plan to change. -- A type error requires adding a cast or suppression to resolve. -- A test requires mocking something that was not mocked before. -- The same bug has been fixed more than once in this session. -- A replacement did not change the file (silent no-op). -- The error in the browser console shows a different bundle hash than the latest build output. -- A "fix" has been applied but the same error reproduces unchanged. +- A fix touches >3 files you did not plan to change; a type error needs a cast/suppression; the same bug is "fixed" more than once; a replacement is a silent no-op; a browser error shows a stale asset hash. --- ## Do Not -- Do not add a backend, API routes, or server-side rendering. -- Do not add external font or icon CDN links. All assets must be self-hosted. -- Do not change `vite.config.ts` base path without verifying GitHub Pages routing. -- Do not install new dependencies without checking if shadcn/ui or an existing utility covers the need. +- Do not add a backend, API routes, or SSR (`output` stays `static`). +- Do not add external font or icon CDN links; all assets self-hosted. +- Do not change `base` handling without verifying GitHub Pages + PR-preview routing. +- Do not install a new dependency without checking an existing lib/primitive covers it. - Do not commit secrets, tokens, or credentials. -- 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. -- Do not re-derive data from `ADVENTURES` inside component files. -- Do not edit `*.generated.ts`, `src/data/adventures/index.ts`, or `src/data/adventures/summaries.ts` by hand. +- Do not change the `@theme` block in `src/styles/index.css` without verifying it doesn't break components. +- Do not edit the copied data types by hand expecting a generator to reconcile — there is no generator; the YAML and the Zod schema are the source of truth. --- ## When Suggesting Code -- Always read `styleguide.md` before making any UI, copy, or component changes. -- Follow all rules in the Styling and Components sections. -- Flag any accessibility concerns before writing the code, not after. Read `ACCESSIBILITY.md` first. -- Flag any breaking changes explicitly. -- Prefer simple, readable solutions over clever ones. -- If something could be done multiple ways, briefly explain the tradeoff and recommend one approach. +- Read `styleguide.md` before UI/copy/component changes. Follow the Styling and Components sections. +- Flag accessibility concerns before writing code (read `ACCESSIBILITY.md`). Flag breaking changes explicitly. +- Prefer simple, readable solutions. If multiple approaches exist, state the tradeoff and recommend one. --- @@ -777,26 +439,12 @@ Every code change must pass all of these checks before being considered done. St A task is not done until the relevant docs are updated. -### Always check these four things after any non-trivial change - -1. **Did you add or change a component, hook, or utility?** Update `styleguide.md`. -2. **Did you add or change a page or route?** Update the routes table in `README.md`. -3. **Did you add or change an environment variable, constant, or config value?** Document it in `README.md`. -4. **Did you change a build, deploy, or dev workflow?** Update the Commands section in both `CLAUDE.md` and `README.md`. +1. New/changed component, island, or utility? Update `styleguide.md`. +2. New/changed page or route? Update the routes table in `README.md` (and the test route lists + sitemap). +3. New/changed constant or config value? Document it in `README.md`. +4. Changed a build/deploy/dev workflow? Update the Commands section in `CLAUDE.md` and `README.md`. -After completing any task, explicitly state which checks applied, what was updated, or why it was skipped. - -| Change | Update | -| --- | --- | -| New component | styleguide.md: component entry with props and usage | -| New hook | styleguide.md: hook entry with return type and behavior | -| New utility function | styleguide.md: brief entry if it affects patterns | -| New page or route | README.md routes table; sitemap.xml and prerender array for static routes | -| New constant | README.md constants section, styleguide.md if visual | -| New workflow step | README.md commands section, CLAUDE.md if it changes a rule | -| New brand or copy rule | styleguide.md first, then apply across codebase | -| Bug fix that reveals a missing rule | CLAUDE.md: add the rule to prevent recurrence | -| New test pattern | CLAUDE.md: add to Testing section if it sets a precedent | +State which checks applied and what was updated (or why skipped). --- @@ -804,44 +452,20 @@ After completing any task, explicitly state which checks applied, what was updat ### Shared state -If a hook or piece of state is consumed by more than one sibling component, it must be a React context provider, not a plain hook. - -### File extensions - -Any file that renders or returns JSX must use the `.tsx` extension. Files that are pure TypeScript logic with no JSX use `.ts`. - -### React hooks +State consumed by more than one component lives in a **nanostore** (`src/stores/`). In `.astro` inline scripts, read the store directly via `.subscribe(callback)` or `.get()`. When the first Vue island needing shared state is created, install `@nanostores/vue` and use its `useStore` composable inside the island. -Each `useEffect` must have a single responsibility. Never combine side effects with different trigger conditions into one effect. Split them. +Do not duplicate cross-island state in component refs. -Every `useEffect` that creates a `setTimeout`, `setInterval`, or event listener must return a cleanup function that cancels it. Clear and reassign timer refs before setting a new one so rapid re-fires don't stack. Example: +### File extensions -```tsx -const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); -useEffect(() => (): void => { - if (timerRef.current !== null) clearTimeout(timerRef.current); -}, []); -// in handler: -if (timerRef.current !== null) clearTimeout(timerRef.current); -timerRef.current = setTimeout(() => setState(false), 1500); -``` +Static, zero-JS UI is `.astro`. Interactive islands are `.vue`. Pure logic is `.ts`/`.mjs`. Build-time-only pipeline modules are `.mjs`. ### State machines -When implementing any feature with multiple states, enumerate every transition before writing code. For each transition, list every system that must be updated (storage, UI state, external APIs, DOM). +Enumerate every transition before writing code. For each, list every system that must update (localStorage, store state, DOM, `gtag`/dataLayer). The consent machine table above is the reference. --- -## SEO Checklist: Required for Every New Page - -Add via the route module's `meta()` export, never in `src/root.tsx`: - -- `<title>` (unique) and `<meta name="description">` (under 160 chars) -- `og:title`, `og:description`, `og:url`, `og:type`, `og:image`, `og:image:width` (1200), `og:image:height` (630), `og:image:alt`, `og:site_name`, `og:locale` (en_GB) -- `twitter:card` (`summary_large_image`), `twitter:title`, `twitter:description`, `twitter:image`, `twitter:image:alt` -- `<link rel="canonical">` set to `${SITE_URL}${pathname}` -- Correct heading hierarchy: one `h1`, `h2` for sections, `h3` for subsections - -Static routes only: add to `public/sitemap.xml` and the `prerender` array in `react-router.config.ts`. +## Known follow-ups (post-migration) -One-time `src/root.tsx` check (not per page): manifest link, both theme-color tags, JSON-LD block, `lang="en"` on `<html>`. +No open cleanups. (Done: Shiki dual-theme syntax highlighting for code blocks, lint, sitemap endpoint, consent runtime tests `e2e/consent.spec.ts`, gated click-event tracking, the full React-parity restoration of the home/adventures/challenges pages + nav/footer chrome, the code-block header + Copy button, the abbr JS tooltip — a `position:fixed` portal in `Layout.astro` that clamps to the viewport and escapes overflow clipping, and the full component-by-component `styleguide.md` rewrite with verified prop types.) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c2e2c7b7..9bfd107f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ git clone https://github.com/<your-username>/website.git cd website nvm use npm install -npm run dev # http://localhost:8080 +npm run dev # http://localhost:4321 ``` 1. Add the upstream remote so you can pull in future changes: @@ -34,7 +34,7 @@ Open PRs from your fork against `main` on the upstream repo. ```sh npm run lint # ESLint npm run lint:reuse # REUSE licence compliance (requires: pip install reuse) -npm test # Vitest unit tests +npm run test:unit # Vitest unit tests npm run build && npm run test:e2e # Playwright smoke, SSG, a11y, and hydration tests ``` @@ -57,9 +57,25 @@ git commit -s -m "feat: add contributor badge" ## Code style - TypeScript with explicit return types on all functions and components. -- Functional components with hooks only. No class components. -- Tailwind utility classes directly on JSX. No inline styles. +- Tailwind utility classes directly on elements. No inline styles. - Both light and dark mode must work for every UI change. +- Inline links in prose need `{" "}` around them. Astro strips the whitespace + between text and an adjacent element when the source has a newline there, so + `See our\n<a>Privacy Policy</a>\nfor details.` renders with no spaces. See + [styleguide.md](styleguide.md) for the detail. + +### Interactive components + +- Default to a `.astro` component with a plain `<script>`. Reach for a framework + only when the component has genuinely reactive state that a class toggle and a + small script cannot express. +- **When one is warranted, use Vue. Never React.** The `@astrojs/vue` + integration stays installed even though nothing currently uses it, so adding + an island is a one-file change. Do not remove the Vue packages as "unused". +- The site currently ships zero islands. The theme toggle, mobile drawer, + consent banner, starter nudge and challenge filter were all islands once and + are now markup plus a script; they are the bar for what does *not* justify a + framework. Full rules are in [AGENTS.md](AGENTS.md) (or [CLAUDE.md](CLAUDE.md) for Claude Code users) and [styleguide.md](styleguide.md). @@ -87,7 +103,7 @@ Every component must meet WCAG 2.2 AA. Read [ACCESSIBILITY.md](ACCESSIBILITY.md) ## Adventure content -Adventures are authored as YAML and compiled to TypeScript. Do not edit `*.generated.ts` files by hand. See [ADVENTURES.md](ADVENTURES.md) for the full content pipeline. +Adventures are authored as YAML. The YAML is the source of truth; there are no generated files to commit or maintain. See [ADVENTURES.md](ADVENTURES.md) for the full content pipeline. ## Need help? diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 4f42cc3f7..aaeda5bf4 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -2,6 +2,8 @@ This file applies to all work on offon.dev. Read it before adding fonts, images, dependencies, or new routes. +> **Post-migration note:** the site is now Astro + Vue islands, not React Router. The performance *principles* below (image rules, font subsetting/preloading, self-hosting, "ship less JS") still hold, but some mechanics are superseded: global font preloads live in `src/layouts/Layout.astro` (not `root.tsx`); code-splitting and "zero JS by default" come from Astro islands (not React Router / `React.lazy`); markdown is pre-rendered by the content collection (`src/content.config.ts` + `src/lib/markdown-pipeline.mjs`), not a generator; prefetching uses Astro's native `prefetch` config (not a `SPECULATION_RULES` script); the build outputs `dist/` (not `dist/client/`); routes come from `getStaticPaths()` (no `react-router.config.ts` prerender array). Where this doc and `CLAUDE.md` disagree, `CLAUDE.md` wins. + --- ## Targets @@ -37,7 +39,7 @@ Target these thresholds at the 75th percentile of real users: - Do not lazy-load the LCP image. Remove `loading="lazy"` from any above-the-fold image. - Add `fetchpriority="high"` to the LCP image. - If bitmap images are added to the site, prefer WebP over JPEG/PNG. For maximum compression, serve AVIF with a WebP fallback via `<picture>`. `public/og.png` must stay as PNG -- Open Graph crawlers do not reliably support modern formats. -- `public/og.png` is 1200 x 630 px (the standard OG image size). If the image is ever recreated, export at those exact dimensions and update `og:image:width`/`og:image:height` in `src/lib/meta.ts`, the tests in `src/test/meta.test.ts` and `src/test/seo.test.ts`, and the brand guidelines page `src/pages/BrandGuidelines.tsx`. +- `public/og.png` is 1200 x 630 px (the standard OG image size). If the image is ever recreated, export at those exact dimensions and update `og:image:width`/`og:image:height` in `src/components/SEO.astro`. --- @@ -45,31 +47,19 @@ Target these thresholds at the 75th percentile of real users: - All fonts are self-hosted under `public/fonts/`. Never add an external font CDN link. - `font-display: optional` is set on all fonts. This means the browser has a very short (~100 ms) window to load a font before permanently falling back to the system font for that page visit. Preloading is therefore required for fonts to render correctly on throttled connections. -- **Global preloads** go in the `links()` export in `src/root.tsx`. Use this for fonts that appear above the fold on every page. Currently preloaded globally: Inter 400, 500, 600 (body text and semibold/bold labels); Syne 700 (h1–h2 via the `@layer base` rule). Inter 700 is **not** preloaded globally. It is used only for h3–h6, which never appear above the fold. -- **Route-level preloads** go in the `links()` export of a specific route module. Use this for fonts that are only used on certain pages to avoid "preloaded but not used" warnings and wasted bandwidth on other pages. Do not add JetBrains Mono to the global preloads. - - **JetBrains Mono 400 and 600** are preloaded on `Index.tsx`, `Challenges.tsx`, `AdventureDetail.tsx`, and `ChallengeDetail.tsx`. These routes render both normal and semibold mono elements. - - **JetBrains Mono 400 only** is preloaded on `Adventures.tsx`, `Contribute.tsx`, and `CommunityGuide.tsx`. These routes render mono elements but none use `font-semibold`, so the 600-weight file would be preloaded but unused. - - **Rule: only preload a font weight if at least one element on that route uses it.** Preloading an unused weight generates a browser warning on every page visit and wastes bandwidth. Before adding a 600-weight preload to a route, confirm a `font-semibold font-mono` element exists in the component tree for that route. - - ```ts - // Routes with both 400 and semibold (600) mono usage: - export const links: LinksFunction = () => [ - { rel: "preload", href: `${import.meta.env.BASE_URL}fonts/jetbrains-mono-latin-400-normal.woff2`, as: "font", type: "font/woff2", crossOrigin: "anonymous" }, - { rel: "preload", href: `${import.meta.env.BASE_URL}fonts/jetbrains-mono-latin-600-normal.woff2`, as: "font", type: "font/woff2", crossOrigin: "anonymous" }, - ]; - ``` - -- The `src/index.css` `@font-face` declarations cover only the `latin` and `latin-ext` subsets. Non-English subset declarations (cyrillic, greek, vietnamese) were removed from CSS. The site is English-only and `unicode-range` already prevented those files from being fetched, but the declarations added unnecessary CSS weight. Note: the corresponding `.woff2` files remain in `public/fonts/` but are never declared in CSS and will never be fetched by the browser. -- When adding a new route that uses JetBrains Mono (e.g. a page with code blocks or difficulty badges), add only the weight preloads the route actually needs to that route's `links()` export. +- **Global preloads** go in `src/layouts/Layout.astro`'s `<head>` section as `<link rel="preload">` tags. Currently preloaded globally: Inter 400, 500, 600 (body text and semibold/bold labels); Syne 700 (h1–h2 via the `@layer base` rule). Inter 700 is **not** preloaded globally — it is used only for h3–h6, which never appear above the fold. +- There are no route-level preload exports (no `links()` function). Fonts needed only on specific pages must be added as `<link rel="preload">` in that page's frontmatter or in a layout variant. + - **Rule: only preload a font weight if at least one element on that page uses it.** Preloading an unused weight generates a browser warning on every page visit and wastes bandwidth. +- The `src/styles/index.css` `@font-face` declarations cover only the `latin` and `latin-ext` subsets. The corresponding `.woff2` files for non-English subsets remain in `public/fonts/` but are never declared in CSS and will never be fetched. --- ## JavaScript and bundle size -- Route-level code splitting is handled automatically by React Router v8. No manual `React.lazy` or `Suspense` wrappers are needed or should be added. +- Astro ships zero JS by default. Code splitting only matters for Vue islands, which Vite handles automatically — no manual `defineAsyncComponent` needed. - Never use `will-change` on more than 3 elements simultaneously. - Before adding any new dependency, run `npm run build` and check the bundle output. -- **Do not introduce a runtime markdown-rendering component into `AdventureCard`, `ChallengesGrid`, or any component they transitively import.** All Markdown is pre-rendered at build time by `scripts/generate-adventures.mjs`. Adventure story fields render as plain text. Adding a runtime renderer (e.g. one wrapping `react-markdown`) would pull `react-markdown` + `remark-gfm` (~46 kB gz) into the home page bundle. The generator warns at build time if any story field contains markdown syntax. +- **Do not introduce a runtime markdown-rendering component into `AdventureCard`, `ChallengesGrid`, or any component they transitively import.** All markdown in adventure YAML is pre-rendered to sanitised HTML at build time by the content collection (`src/content.config.ts` + `src/lib/markdown-pipeline.mjs`). Story fields render as plain text via `stripHtml`. Adding a runtime markdown renderer would bloat the main bundle with packages that the build already uses only once at build time. --- @@ -86,7 +76,7 @@ Target these thresholds at the 75th percentile of real users: - Use `defer` for app scripts that depend on the DOM and on relative execution order. - Use `async` for independent third-party scripts (analytics loaders, chat widgets) that have no execution-order dependencies. - Never place a bare `<script src="...">` in `<head>` without `defer` or `async`. -- React Router v8 generates `type="module"` scripts automatically. Do not override this. +- Astro generates `type="module"` scripts for islands automatically. Inline `.astro` scripts are hoisted and bundled. Do not override this. - See the Analytics and Consent section in `CLAUDE.md` for the pattern used by the `gtag.js` injector. It is appended to `<body>` after consent, never blocking. --- @@ -101,7 +91,7 @@ Target these thresholds at the 75th percentile of real users: ## Visibility-aware rendering - For pages with long lists of off-screen content (e.g. a large challenges grid), consider `content-visibility: auto` with `contain-intrinsic-size` to defer layout and paint for content below the fold. -- Intersection Observer is the correct API for any lazy behaviour tied to scroll position. Create observers inside `useEffect`, never at module level. Guard with `typeof window !== 'undefined'`. +- Intersection Observer is the correct API for any lazy behaviour tied to scroll position. Create observers inside `astro:page-load` (for `.astro` scripts) or `onMounted` (for Vue islands), never at module level. - Never use scroll or resize listeners for visibility detection. They run on the main thread every frame and should be replaced with Intersection Observer. --- @@ -122,10 +112,8 @@ Target these thresholds at the 75th percentile of real users: ## Prefetching -- The site uses the Speculation Rules API to prefetch challenge and adventure pages. The rules are defined as `SPECULATION_RULES` in `src/root.tsx` and injected via DOM in a `useEffect`, not as static JSX. -- The DOM-injection approach is intentional. If a `<script type="speculationrules">` element appears in JSX, React's reconciler may touch it after the browser has already processed it, emitting the warning "Inline speculation rules cannot currently be modified after they are processed." DOM injection sidesteps this entirely. -- To update which paths are prefetched, edit the `SPECULATION_RULES` constant in `src/root.tsx`. Do not change the injection approach. -- Do not add a second `<script type="speculationrules">` element anywhere. The `useEffect` guard prevents duplicate injection, but two competing rule sets would cause unpredictable behaviour. +- Astro's native `prefetch` option (configured in `astro.config.mjs`) handles prefetching. No custom Speculation Rules script is needed or present. +- Do not add a `<script type="speculationrules">` element manually. If broader prefetch coverage is needed, extend the Astro `prefetch` config instead. --- @@ -166,7 +154,7 @@ The site is currently hosted on GitHub Pages. GitHub Pages cannot set arbitrary ### How to migrate 1. Add the site to a Cloudflare account and point DNS to Cloudflare nameservers. -2. In Cloudflare Pages, connect the GitHub repo and configure the build command (`npm run build`) and output directory (`dist/client`). +2. In Cloudflare Pages, connect the GitHub repo and configure the build command (`npm run build`) and output directory (`dist/`). 3. Add a `_headers` file to `public/` (Cloudflare Pages reads it automatically) with the security and cache-control headers. 4. Remove the `deploy.yml` GitHub Actions workflow or repurpose it to trigger a Cloudflare Pages deploy hook. 5. Submit the domain to the HSTS preload list at <https://hstspreload.org> once HSTS is confirmed working. @@ -193,6 +181,5 @@ The site is currently hosted on GitHub Pages. GitHub Pages cannot set arbitrary ## New routes -- New routes are automatically code-split by Vite. No manual action needed. -- When adding a new static route, add it to `src/routes.ts`, `public/sitemap.xml`, the `prerender` array in `react-router.config.ts`, and the routes table in `README.md`. -- See the Site Maintenance section in `CLAUDE.md` for the full route checklist. +- Routes come from file-based pages and `getStaticPaths()`. No prerender array exists. +- See the "Routes" section in `CLAUDE.md` for the full checklist: add new static pages to `PAGES` in `e2e/a11y.spec.ts`, `ROUTES` in `e2e/smoke.spec.ts`, `staticPaths` in `src/pages/sitemap.xml.ts`, and the routes table in `README.md`. diff --git a/README.md b/README.md index dc0cbfac3..ccfc495cf 100644 --- a/README.md +++ b/README.md @@ -1,245 +1,148 @@ # offon.dev -Source for [offon.dev](https://offon.dev/), the home of OffOn: a platform for open source enthusiasts. The site is fully static with no backend. Pages are prerendered at build time using React Router v8 framework mode with `ssr: false`. It hosts hands-on open source challenges, community documentation, and links to the OffOn community. +Source for [offon.dev](https://offon.dev/), the home of OffOn: a platform for open source enthusiasts. The site is fully static with no backend. Pages are prerendered at build time by **Astro**; interactivity is added as `.astro` components with vanilla `<script>` blocks. It hosts hands-on open source challenges, community documentation, and links to the OffOn community. ## Tech Stack -- **React 19** + **TypeScript**: UI and type safety -- **Vite**: build tooling and dev server -- **Tailwind CSS**: utility-first styling -- **shadcn/ui**: minimal component surface (`badge.tsx`, `tooltip.tsx`); most Radix UI packages were intentionally removed -- **React Router v8**: client-side routing with static prerendering -- **Vitest**: unit and component testing -- **Playwright**: browser smoke tests (`e2e/`) +- **Astro 7** (`output: 'static'`) + **TypeScript**: prerendered pages, zero JS by default +- **Vue 3** via `@astrojs/vue`: retained for future islands; the site currently ships **zero islands** — all interactive surfaces are `.astro` + vanilla script +- **nanostores**: shared store state (consent); read directly via `.subscribe()`/`.get()` in inline scripts +- **Tailwind CSS 4**: CSS-first via `src/styles/index.css` (`@theme`) and `@tailwindcss/vite` +- **unplugin-icons** (lucide) via `~icons/lucide/*`; custom `abbr[data-title]` tooltip portal in `Layout.astro` +- **Astro Content Collections** (Zod): adventure content authored as YAML, validated + rendered at build time +- **Playwright** + **axe**: accessibility and SEO/smoke tests (`e2e/`) - **GitHub Pages**: hosting and deployment ## Getting Started ```sh -# Clone the repo git clone https://github.com/off-on-dev/website cd website - -# Install dependencies npm install - -# Start the development server (http://localhost:8080) -npm run dev +npm run dev # Astro dev server (http://localhost:4321) ``` -Node.js **26** is required. Version is pinned in `.nvmrc`, run `nvm use` to switch automatically. +Node.js **26** is required (pinned in `.nvmrc`; `nvm use`). ## Scripts | Script | Description | | --- | --- | -| `npm run dev` | Start local dev server at <http://localhost:8080> | -| `npm run build` | SSG prerender build to `dist/client/` (React Router v8); postbuild creates `_.data` aliases for GitHub Pages trailing-slash compatibility | -| `npm run build:dev` | Dev-mode build (source maps, no minification) | -| `npm run preview` | Serve the production build locally. `serve.json` at the repo root sets `trailingSlash: true` to mirror GitHub Pages URL behaviour | -| `npm run lint` | Run ESLint across the project | +| `npm run dev` | Astro dev server at <http://localhost:4321> | +| `npm run build` | Static build to `dist/` | +| `npm run preview` | Serve the built `dist/` (`astro preview`) | +| `npm run sync` | `astro sync` — runs the Zod content schema over adventure YAML; fails on invalid content | +| `npm run test:unit` | Vitest unit tests (lib functions, consent store, Vue components) | +| `npm run test:unit:watch` | Vitest in watch mode during development | +| `npm run test:e2e` | Playwright (a11y + SEO/smoke). Requires `npm run build` first; `astro preview` serves the built `dist/` | | `npm run lint:reuse` | REUSE licence compliance check (requires `pip install reuse` once) | -| `npm test` | Run the full test suite once (Vitest) | -| `npm run test:watch` | Run tests in watch mode | -| `npm run test:coverage` | Run tests with v8 coverage report | -| `npm run test:e2e` | Playwright smoke, SSG, accessibility, and hydration tests (requires `npm run build` first) | -| `npm run test:visual` | Visual regression tests against baseline screenshots (requires `npm run build` first) | -| `npm run test:visual:update` | Update visual regression baseline screenshots | -| `npm run generate` | Regenerate TypeScript from adventure YAML files | -| `npm run generate:validate` | Validate adventure YAML against schema without writing files | -| `npm run generate:solutions` | Regenerate solution barrel index from `src/data/solutions/` TypeScript files | -| `npm run generate:solutions:validate` | Validate solution TypeScript files without writing the barrel index | | `node .ai/templates/generate-reveal-zip.mjs` | Regenerate `public/downloads/offon-reveal-template.zip` | | `node .ai/templates/generate-pptx.mjs` | Regenerate `public/downloads/offon-deck-template.pptx` | -Run `npm run lint` and `npm test` before marking any work done. +There is no content generator — routes and rendered prose come from the content collection at build time. -All UI changes must be verified at mobile (375px), tablet (768px), and desktop (1280px) viewports before being considered done. Always test against the production build (`npm run build && npm run preview`), never the dev server. +Always verify UI changes at mobile (375px), tablet (768px), and desktop (1280px) against the production build (`npm run build && npm run preview`), never the dev server. ## Project Structure ```text src/ - components/ # Reusable UI components (named exports, PascalCase files) - components/ui/ # shadcn/ui primitives, do not edit directly - pages/ # Route-level page components - data/ # Static content as typed TypeScript objects and arrays - data/adventures/<id>/adventure.yaml # Adventure YAML source files - hooks/ # Custom React hooks - lib/ # Shared utilities - test/ # Vitest + Testing Library unit and component tests - root.tsx # HTML shell rendered by React Router v8 (replaces index.html) - routes.ts # Route definitions (React Router v8 config-based routing) - entry.client.tsx # Client entry: hydrates the full document via HydratedRouter - entry.server.tsx # Server/prerender entry: renderToPipeableStream for static HTML generation - Layout.tsx # App shell with all providers and Outlet + pages/ # File-based routes (.astro); dynamic routes use getStaticPaths() + index.astro, adventures/[id].astro, adventures/[id]/levels/[levelId].astro, + adventures/[id]/levels/[levelId]/solution.astro, challenges/[...tag].astro, + 404.astro, the static pages, and _app.ts (Vue appEntrypoint) + layouts/Layout.astro # App shell: <head> (SEO, CSP, favicons, theme + GA4 bootstrap, + # JSON-LD), ClientRouter, skip-nav, Navbar, <slot/>, Footer, ConsentBanner + components/ # *.astro (static, zero-JS) with inline scripts; *.vue reserved for future islands + content.config.ts # Content collection: Zod schema + custom loader + build-time markdown rendering + data/ # adventures/<id>/adventure.yaml + *-posts.json + leaderboard.json, + # solutions/<id>/<level>.ts, contributors.ts, types.ts, sponsors.ts, team.ts + lib/ # markdown-pipeline.mjs, adventure-derive.mjs, community-data.ts, solutions.ts, + # challenges.ts, difficulty.ts, markdown.ts, utils.ts, site.ts (constants), deadline.mjs + stores/ # nanostores: consent.ts ($consent + gtag injector) + styles/index.css # Tailwind @theme, component classes, light-mode overrides + assets/diagrams/ # Architecture SVGs (imported per-level via import.meta.glob) e2e/ - smoke.spec.ts # Playwright smoke tests: route titles, axe a11y audit (requires npm run build first) - visual.spec.ts # Visual regression tests: screenshot comparison against baselines (requires npm run build first) - wsg.spec.ts # Web Sustainability Guidelines checks: page weight, third-party requests, image optimisation - a11y.spec.ts # Targeted accessibility checks: keyboard navigation, Windows High Contrast Mode - hydration.spec.ts # Hydration verification: confirms prerendered HTML matches client hydration -schemas/ - adventure.schema.json # JSON Schema for adventure YAML validation -scripts/ - generate-adventures.mjs # YAML -> TypeScript codegen (runs as prebuild hook) - generate-solutions.mjs # Regenerate solution barrel index from src/data/solutions/ - generate-community-sitemap.mjs # Regenerate community sitemap (community.offon.dev topics) - create-data-aliases.mjs # Copy *.data files to <name>/_.data for trailing-slash GitHub Pages (runs as postbuild hook) - sync-adventure.mjs # Fetch and transform adventure YAML from the challenges repo - set-discussion-url.mjs # Set a Discourse thread URL on a level (called by add-discussion-url.yml) - refresh-discussions.mjs # Fetch latest discussion posts per level (called by refresh-community-data.yml) - refresh-leaderboard.mjs # Fetch leaderboard data (called by refresh-community-data.yml) - refresh-community-leaders.mjs # Fetch community leader data (called by refresh-community-data.yml) - check-docs.sh # Validate styleguide.md and README.md were updated alongside code changes - lib/ - deadline.mjs # Normalises human-readable deadline strings to ISO 8601 - yaml-text-edit.mjs # Targeted text-based helpers for editing adventure YAML without reformatting - level-constants.mjs # Level difficulty and ordering constants shared by scripts - level-sync.mjs # Pure helpers used by sync-adventure (level selection and "Coming Soon" computation) -public/ - fonts/ # Self-hosted Inter, Syne, and JetBrains Mono font files - sitemap.xml - robots.txt - og.png + a11y.spec.ts # axe (dark/light/forced-colors) + touch targets + focus rings + 200% zoom + smoke.spec.ts # per-route title/canonical/OG/h1 + island hydration +scripts/ # refresh-*.mjs (community data), sync-adventure.mjs, set-discussion-url.mjs, + # generate-community-sitemap.mjs, check-docs.sh, lib/ +public/ # copied verbatim to dist/ (fonts, favicons, brand, well-known, decks, og.png) +astro.config.mjs, tsconfig.json, playwright.config.ts, package.json ``` ### Adventure Content Pipeline -Adventures are authored as YAML at `src/data/adventures/<id>/adventure.yaml` and compiled to TypeScript by `scripts/generate-adventures.mjs`. The `prebuild` hook runs the generator automatically before every build. +Adventures are authored as YAML at `src/data/adventures/<id>/adventure.yaml` and loaded by `src/content.config.ts` (Astro Content Collection): -- **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. -- **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. +- **Source of truth:** the YAML files. There are no generated `*.ts` files to commit. +- **Validation:** a Zod schema (`.strict()`) runs via `npm run sync`; invalid YAML fails the build. +- **Rendering:** author markdown fields are converted to sanitised HTML in the collection loader (`src/lib/markdown-pipeline.mjs`) at build time. `getCollection('adventures')` returns data with HTML fields ready to render via `set:html`. +- **Discussion/leaderboard** JSON is read at build time (`src/lib/community-data.ts`) and rendered statically. **Solutions** are pre-built TS objects loaded via `import.meta.glob`. +- **Sync from challenges repo:** the `sync-adventure` GitHub Actions workflow writes the YAML + discussion stubs; routes appear automatically via `getStaticPaths()`. ## Routes | Path | Page | Purpose | | --- | --- | --- | -| `/` | `Index.tsx` | Home page | -| `/adventures` | `Adventures.tsx` | Adventure landing hub (links to /challenges) | -| `/adventures/:id` | `AdventureDetail.tsx` | Adventure landing | -| `/adventures/:id/levels/:levelId` | `ChallengeDetail.tsx` | Individual challenge | -| `/adventures/:id/levels/:levelId/solution` | `SolutionDetail.tsx` | Solution walkthrough (post-deadline) | -| `/contribute` | `Contribute.tsx` | How to contribute (technical and non-technical ways) | -| `/sponsors` | `Sponsors.tsx` | Sponsorship info | -| `/about` | `About.tsx` | About the community | -| `/handbook` | `CommunityGuide.tsx` | Community handbook / documentation | -| `/privacy` | `Privacy.tsx` | GDPR-compliant privacy policy | -| `/accessibility` | `Accessibility.tsx` | WCAG accessibility statement | -| `/brand` | `BrandGuidelines.tsx` | Brand guidelines: logos, colors, typography, voice | -| `/presentation-templates` | `PresentationTemplates.tsx` | Slide template downloads (Reveal.js ZIP, PPTX); unlisted, noindex, excluded from sitemap and robots | -| `/404` | `NotFound.tsx` | Prerendered 404 page | -| `/community-guide` | redirects to `/handbook` | Legacy alias | -| `/docs` | redirects to `/handbook` | Legacy alias | -| `/docs/community-guide` | redirects to `/handbook` | Legacy alias | -| `/challenges` | `Challenges.tsx` | All challenges across all adventures; filter by technology tag | -| `/challenges/:tag` | `Challenges.tsx` | Challenges filtered by technology tag (SEO-friendly slug) | -| `*` | `CatchAll.tsx` | Client-side 404 fallback (re-exports `NotFound.tsx`; required because React Router v8 needs unique files per route) | - -> **Technology tag filtering** is handled inline on the home page via local `useState`. Adventure detail and challenge detail pages link tags to `/challenges/:tag`. The `/challenges` page uses URL params for shareable filtered views. +| `/` | `index.astro` | Home page | +| `/adventures/` | `adventures/index.astro` | Adventures list | +| `/adventures/:id/` | `adventures/[id].astro` | Adventure detail | +| `/adventures/:id/levels/:levelId/` | `adventures/[id]/levels/[levelId].astro` | Individual challenge | +| `/adventures/:id/levels/:levelId/solution/` | `.../solution.astro` | Solution walkthrough (post-deadline) | +| `/challenges/` and `/challenges/:tag/` | `challenges/[...tag].astro` | All challenges; filter by technology tag | +| `/contribute/`, `/sponsors/`, `/about/`, `/handbook/` | static `.astro` pages | Contribute, sponsors, about, handbook | +| `/privacy/`, `/accessibility/`, `/brand/` | static `.astro` pages | Privacy (noindex), accessibility statement, brand guidelines | +| `/presentation-templates/` | static `.astro` page | Slide template downloads (noindex) | +| `/404/` | `404.astro` | 404 page (`dist/404.html`, served by GitHub Pages) | +| `/docs`, `/docs/community-guide`, `/community-guide` | redirects → `/handbook/` | Legacy aliases (`redirects` in `astro.config.mjs`) | + +> The `/challenges` filter is a static `.astro` component with a vanilla script. The full grid is server-rendered (works without JS); the script handles topic/difficulty filtering and syncs `?topics`/`?difficulty` to the URL. Adventure and challenge pages link tags to `/challenges/:tag/`. ## SEO and Metadata -### Web Manifest - -`public/site.webmanifest` defines the web app identity, used by browsers and PWA tools. It includes: - -- App name, short name, and description -- Icon references (favicon and apple-touch-icon) -- Theme and background colors -- Display mode (standalone) - -### Schema.org Structured Data - -`src/root.tsx` includes two JSON-LD `<script>` blocks: one with `@type: "WebSite"` and one with `@type: "Organization"`. These help search engines understand the site's identity and content. - -### Open Graph Tags - -All pages include complete OG tags: - -- `og:title`, `og:description`, `og:url`, `og:image` -- `og:image:width`, `og:image:height`, `og:image:alt` (required for proper image rendering in social previews; the alt text is the `OG_IMAGE_ALT` constant from `src/data/constants.ts` — a fixed description of the brand card, not the page title) -- `og:site_name` (brand), `og:locale` (en_GB) -- `og:type` (website or article, depending on page) - -All dynamic pages (adventure & challenge details) generate page-specific OG tags via React Router v8 `meta()` exports. - -### Twitter Card Tags - -All pages include: - -- `twitter:card` (summary_large_image) -- `twitter:title`, `twitter:description`, `twitter:image`, `twitter:image:alt` - -### Canonical Links - -Each page declares its canonical URL to prevent duplicate indexing. Handled via React Router v8 `meta()` exports on each route module. - -### Sitemap and Robots - -- `public/sitemap.xml` lists all static routes with change frequency and priority. -- `public/robots.txt` points search engines to the sitemap. - ---- +- **Per-page meta** comes from the `<SEO>` component (`src/components/SEO.astro`), fed by `Layout.astro` props: `<title>`, `<meta name="description">`, canonical (`${SITE_URL}${path}`), Open Graph (`og:title/description/type/url/image` + width/height/`OG_IMAGE_ALT`, `og:site_name`, `og:locale` en_GB), and Twitter card tags. Legal pages pass `noindex`. +- **Global head** (`Layout.astro`): charset, viewport, `color-scheme`, favicons, manifest, both `theme-color` tags, CSP meta, and two JSON-LD blocks (`WebSite` + `Organization`). +- **Web manifest:** `public/site.webmanifest` (name, icons, theme/background colors, standalone display). +- **Sitemap/robots:** Sitemap generated at build time by `src/pages/sitemap.xml.ts`; and `public/robots.txt`. ## Analytics and Privacy -The site uses Google Analytics 4 with Consent Mode v2 in **gated-load mode**. No data is sent to Google until the user clicks Accept on the cookie banner. The `gtag.js` script itself is not loaded until that point. Cross-domain measurement between `offon.dev` and `community.offon.dev` is configured in the GA4 admin UI, not in this codebase. See the Analytics and Consent section of `CLAUDE.md` for the full design. - -### Configuration +Google Analytics 4 with Consent Mode v2 in **gated-load mode**: no data is sent to Google until the user clicks Accept; `gtag.js` is not loaded until then. Cross-domain measurement is configured in the GA4 admin UI. See the Analytics and Consent section of `CLAUDE.md` for the full design. -The following constants in `src/data/constants.ts` drive the analytics setup: - -| Constant | Purpose | +| Constant (`src/lib/site.ts`) | Purpose | | --- | --- | -| `GA_MEASUREMENT_ID` | GA4 Measurement ID. Used by `useConsent.tsx` only, when it injects `gtag.js` on Accept. | +| `GA_MEASUREMENT_ID` | GA4 Measurement ID (used by the consent store's gtag injector). | | `CONSENT_STORAGE_KEY` | `localStorage` key for the consent decision. | | `CONSENT_EXPIRY_MS` | Stored consent expiry (180 days). | -### How it works - -- `src/root.tsx` ships a minimal inline `<head>` bootstrap that bootstraps `dataLayer`, defines `window.gtag` as the push shim, and sets all four GDPR consent signals to denied. It does not load gtag.js, does not push `js` or `config`, and does not read `localStorage`. -- `src/hooks/useConsent.tsx` owns the React-side state and the `gtag.js` injector. On Accept (or on mount when localStorage records a granted decision), the injector pushes `consent update granted` + `js` + `config` into `dataLayer` synchronously before appending the `<script src="...gtag/js?id=...">` tag. A module-scoped boolean ensures the script is appended at most once per session. -- `src/components/ConsentBanner.tsx` renders a fixed bottom bar until the user makes a choice. Once consent is set, it renders a floating cookie icon button (bottom-right) that calls `reset()` to reopen the banner. -- `src/Layout.tsx` fires `page_view` on every route change and `click_event` for every `<a>`/`<button>` click via `useClickTracking`. Both gate on `consent === "granted"` so events do not accumulate in `dataLayer` while gtag.js is not loaded. -- On Decline or Reset, the hook clears any `_ga*` cookies that gtag.js may have set during a prior granted session. The script tag is not removed. -- `/privacy` (`src/pages/Privacy.tsx`) is the GDPR Art. 13 privacy policy. Contact: <offondev@gmail.com> and `${COMMUNITY_URL}/groups/moderators`. - ---- +- `Layout.astro` ships the minimal inline `<head>` bootstrap (dataLayer + `gtag` shim + all four signals denied; no gtag.js, no `js`/`config`, no localStorage read). +- `src/stores/consent.ts` owns the state (`$consent` nanostore) and the gtag injector; `src/components/ConsentBanner.vue` is the island. `page_view` fires on `astro:page-load` only when consent is granted. On Decline/Reset, `_ga*` cookies are cleared. ## Deployment -Deployment is automated via GitHub Actions: +- **Push to `main`** → [`deploy.yml`](.github/workflows/deploy.yml) builds `dist/` and deploys to GitHub Pages (<https://offon.dev>) via `JamesIves/github-pages-deploy-action`. +- **Open a PR** → [`preview.yml`](.github/workflows/preview.yml) runs the content gate (`astro sync`), build, and the full Playwright suite, then deploys a preview at `/pr-preview/pr-<n>/`. +- **PRs touching adventure data** → [`validate-adventures.yml`](.github/workflows/validate-adventures.yml) validates the YAML (Zod via `astro sync`), per-level discussion JSON, and `ADVENTURE_CATEGORIES` registration. +- **PRs adding components/utilities/constants/scripts/workflows** → [`validate-docs.yml`](.github/workflows/validate-docs.yml) requires `styleguide.md`/`README.md` updates. -- **Push to `main`** triggers [`.github/workflows/deploy.yml`](.github/workflows/deploy.yml), which builds and deploys to GitHub Pages. Production URL: **<https://offon.dev>**. -- **Open a PR** triggers [`.github/workflows/preview.yml`](.github/workflows/preview.yml), which runs lint, build, unit tests, and the full Playwright suite (axe, a11y, smoke) before deploying a preview at `/pr-preview/pr-<n>/`. -- **PRs touching adventure data** also trigger [`.github/workflows/validate-adventures.yml`](.github/workflows/validate-adventures.yml), which validates YAML schema, checks that generated files are up-to-date (including `public/llms.txt`), and verifies sitemap/prerender/leaderboard consistency. -- **PRs adding components, hooks, utilities, constants, scripts, or workflows** trigger [`.github/workflows/validate-docs.yml`](.github/workflows/validate-docs.yml), which runs `scripts/check-docs.sh` and fails if the relevant documentation (`styleguide.md` or `README.md`) was not updated in the same PR. - -`dist/client/404/index.html` (the prerendered 404 page) is copied to `dist/client/404.html` as a fallback for unknown routes. Each valid route has its own prerendered `index.html` so GitHub Pages serves a 200 directly. - -PR preview builds set the `VITE_BASE_PATH` environment variable to `/pr-preview/pr-<n>/` so all asset paths resolve correctly under the preview sub-path. +Astro emits `dist/404.html` natively. PR preview builds set `VITE_BASE_PATH=/pr-preview/pr-<n>/` (→ Astro `base`) so assets resolve under the sub-path; `Layout.astro` marks such builds `noindex`. ## Adding Adventures and Levels -Adventures are authored in [off-on-dev/open-source-challenges](https://github.com/off-on-dev/open-source-challenges) and pulled into this site via the **Sync Adventure from Challenges Repo** GitHub Actions workflow. The workflow fetches content, generates all TypeScript data files, and opens a PR with a checklist of steps to complete before merging. - -See [`ADVENTURES.md`](ADVENTURES.md) for the full guide, including how to complete the PR checklist, how to add a new level to an existing adventure, and what happens when you re-sync a PR that already has manual edits. +Adventures are authored in [off-on-dev/open-source-challenges](https://github.com/off-on-dev/open-source-challenges) and pulled in via the **Sync Adventure from Challenges Repo** workflow, which writes the YAML + discussion stubs and opens a PR. See [`ADVENTURES.md`](ADVENTURES.md) for the full guide. ## Accessibility -OffOn targets WCAG 2.2 Level AA across every page, in both light and dark mode. Automated axe-core scans run on every pull request preview via [`e2e/smoke.spec.ts`](e2e/smoke.spec.ts). The full statement, supported environments, known limitations, and how to report a barrier are in [`ACCESSIBILITY.md`](ACCESSIBILITY.md). Contributor rules are in the Accessibility section of [`CLAUDE.md`](CLAUDE.md#accessibility). +OffOn targets WCAG 2.2 Level AA across every page, in both light and dark mode. Automated axe scans run on every PR preview via [`e2e/a11y.spec.ts`](e2e/a11y.spec.ts). The full statement is in [`ACCESSIBILITY.md`](ACCESSIBILITY.md); contributor rules are in the Accessibility section of [`CLAUDE.md`](CLAUDE.md#accessibility). ## Further Reading -- [`ADVENTURES.md`](ADVENTURES.md): full guide to syncing, reviewing, and updating adventures and levels via GitHub Actions. -- [`ACCESSIBILITY.md`](ACCESSIBILITY.md): public accessibility statement, supported environments, and how to report a barrier. -- [`PERFORMANCE.md`](PERFORMANCE.md): performance targets, image rules, font preloading, and bundle size guidance. -- [`styleguide.md`](styleguide.md): design system, color tokens, typography, component patterns, and light/dark mode rules. -- [`CLAUDE.md`](CLAUDE.md): contributor conventions, code quality rules, commit format, testing requirements, and accessibility standards. +- [`ADVENTURES.md`](ADVENTURES.md): syncing, reviewing, and updating adventures and levels. +- [`ACCESSIBILITY.md`](ACCESSIBILITY.md): public accessibility statement and how to report a barrier. +- [`PERFORMANCE.md`](PERFORMANCE.md): performance targets, image rules, font preloading, bundle size. +- [`styleguide.md`](styleguide.md): design system, color tokens, typography, component patterns. +- [`CLAUDE.md`](CLAUDE.md): contributor conventions, code quality, commit format, testing, accessibility. ## License diff --git a/REUSE.toml b/REUSE.toml index c2357deda..7c9b5f622 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -10,8 +10,10 @@ version = 1 # --- Application source code: MIT --- # src/, e2e/, scripts/, schemas/ contents, plus root-level TS/JS/CSS. +# .astro/.vue added for the Astro + Vue migration (astro/); reuse-tool does not +# auto-recognize .astro, so this glob is what keeps `reuse lint` green for it. [[annotations]] -path = ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs", "**/*.css", "**/*.html"] +path = ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs", "**/*.css", "**/*.html", "**/*.astro", "**/*.vue"] SPDX-FileCopyrightText = "OffOn.dev contributors" SPDX-License-Identifier = "MIT" @@ -29,14 +31,12 @@ SPDX-FileCopyrightText = "OffOn.dev contributors" SPDX-License-Identifier = "MIT" # --- Written content & docs: CC BY 4.0 --- -# Docs, images, authored adventure YAML, generated TS derived from YAML, -# solution walkthroughs, and downloadable templates. -# Utility/structural TS in src/data/ (types, utils, constants) stays MIT via block 1. +# Docs, images, authored adventure YAML, solution walkthroughs, and downloadable +# templates. Utility/structural TS in src/data/ (types, contributors) stays MIT via block 1. [[annotations]] path = [ "**/*.md", "**/*.mdx", "**/*.jpg", "**/*.jpeg", "**/*.webp", "src/data/adventures/*/adventure.yaml", - "src/data/adventures/*.generated.ts", "src/data/solutions/*/*.ts", "public/.well-known/security.txt", "public/llms.txt", "public/llms-full.txt", "public/downloads/**", @@ -44,15 +44,6 @@ path = [ SPDX-FileCopyrightText = "OffOn.dev contributors" SPDX-License-Identifier = "CC-BY-4.0" -# --- community-leaders.json: factual scraped data, no enforceable copyright claim --- -# Usernames, avatar URLs, and post counts scraped from a Discourse instance. -# Annotated CC0-1.0 (public domain dedication) as the most accurate tag for -# factual data that does not qualify as a creative work under copyright law. -[[annotations]] -path = ["src/data/community-leaders.json"] -SPDX-FileCopyrightText = "OffOn.dev contributors" -SPDX-License-Identifier = "CC0-1.0" - # --- ACCESSIBILITY.md: adapted from The Website Specification (CC BY 4.0) --- [[annotations]] path = ["ACCESSIBILITY.md"] @@ -88,22 +79,12 @@ SPDX-FileCopyrightText = [ ] SPDX-License-Identifier = "OFL-1.1" -# --- shadcn/ui generated components: MIT (OffOn.dev contributors + upstream origin) --- -[[annotations]] -path = ["src/components/ui/badge.tsx", "src/components/ui/tooltip.tsx", "src/lib/utils.ts"] -SPDX-FileCopyrightText = [ - "OffOn.dev contributors", - "shadcn (https://ui.shadcn.com)", -] -SPDX-License-Identifier = "MIT" - # --- Adventure content: per-contributor attribution (CC BY 4.0) --- -# These blocks override the global adventure YAML and generated.ts blocks above -# to name the individual who authored each adventure. +# These blocks override the global adventure YAML block above to name the +# individual who authored each adventure. [[annotations]] path = [ "src/data/adventures/blind-by-design/adventure.yaml", - "src/data/adventures/blind-by-design.generated.ts", ] SPDX-FileCopyrightText = [ "OffOn.dev contributors", @@ -114,13 +95,9 @@ SPDX-License-Identifier = "CC-BY-4.0" [[annotations]] path = [ "src/data/adventures/building-cloudhaven/adventure.yaml", - "src/data/adventures/building-cloudhaven.generated.ts", "src/data/adventures/echoes-lost-in-orbit/adventure.yaml", - "src/data/adventures/echoes-lost-in-orbit.generated.ts", "src/data/adventures/lex-imperfecta/adventure.yaml", - "src/data/adventures/lex-imperfecta.generated.ts", "src/data/adventures/the-ai-observatory/adventure.yaml", - "src/data/adventures/the-ai-observatory.generated.ts", ] SPDX-FileCopyrightText = [ "OffOn.dev contributors", @@ -141,3 +118,13 @@ SPDX-FileCopyrightText = [ "Katharina Sick (https://ksick.dev/)", ] SPDX-License-Identifier = "CC-BY-4.0" + +# --- Scraped community data: CC0-1.0 (public domain dedication) --- +# Usernames, avatar URLs, and post counts scraped from a Discourse instance. +# Annotated CC0-1.0 (public domain dedication) as the most accurate tag for +# factual data that does not qualify as a creative work under copyright law. +# Placed last so it overrides the MIT **/*.json glob for this file. +[[annotations]] +path = ["src/data/community-leaders.json"] +SPDX-FileCopyrightText = "OffOn.dev contributors" +SPDX-License-Identifier = "CC0-1.0" diff --git a/astro.config.mjs b/astro.config.mjs new file mode 100644 index 000000000..82788e6d3 --- /dev/null +++ b/astro.config.mjs @@ -0,0 +1,47 @@ +import { defineConfig } from "astro/config"; +import vue from "@astrojs/vue"; +import tailwindcss from "@tailwindcss/vite"; +import Icons from "unplugin-icons/vite"; + +// offon.dev — Astro (static) + Vue islands. base is overridden for PR previews +// via VITE_BASE_PATH (/pr-preview/pr-N/). +const base = process.env.VITE_BASE_PATH ?? "/"; + +export default defineConfig({ + site: "https://offon.dev", + base, + output: "static", + // GitHub Pages normalizes to trailing slashes (also removes the need for any + // RR-style `_.data` alias handling). + trailingSlash: "always", + // Native prefetch replaces the hand-injected speculationrules script. + prefetch: { + prefetchAll: true, + defaultStrategy: "hover", + }, + // Retired URLs → their successor. Astro emits static meta-refresh redirect + // pages (GitHub Pages-compatible). Mirrors src/pages/redirects/*. + redirects: { + "/docs": "/handbook/", + "/docs/community-guide": "/handbook/", + "/community-guide": "/handbook/", + }, + integrations: [vue({ appEntrypoint: "/src/pages/_app" })], + markdown: { + // Build-time dual-theme highlighting (retires the custom CodeBlock highlighter). + // Field-level prose is sanitized separately in src/lib/markdown-pipeline.mjs. + shikiConfig: { + themes: { light: "github-light", dark: "github-dark" }, + // Mirrors THEME_CONTRAST_FIXES in src/lib/markdown-pipeline.mjs: both + // GitHub themes use #6a737d for comments, which fails WCAG 1.4.3 against + // our code-block surfaces in either mode. + colorReplacements: { + "github-dark": { "#6a737d": "#8b949e" }, + "github-light": { "#6a737d": "#57606a" }, + }, + }, + }, + vite: { + plugins: [tailwindcss(), Icons({ compiler: "vue3" })], + }, +}); diff --git a/components.json b/components.json deleted file mode 100644 index e71fe103f..000000000 --- a/components.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "default", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/index.css", - "baseColor": "slate", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - } -} diff --git a/e2e/__screenshots__/404-dark.png b/e2e/__screenshots__/404-dark.png deleted file mode 100644 index 842b5057c..000000000 Binary files a/e2e/__screenshots__/404-dark.png and /dev/null differ diff --git a/e2e/__screenshots__/404-light.png b/e2e/__screenshots__/404-light.png deleted file mode 100644 index 409e756ba..000000000 Binary files a/e2e/__screenshots__/404-light.png and /dev/null differ diff --git a/e2e/__screenshots__/about-dark.png b/e2e/__screenshots__/about-dark.png deleted file mode 100644 index 3f44a7edf..000000000 Binary files a/e2e/__screenshots__/about-dark.png and /dev/null differ diff --git a/e2e/__screenshots__/about-light.png b/e2e/__screenshots__/about-light.png deleted file mode 100644 index 1834afddf..000000000 Binary files a/e2e/__screenshots__/about-light.png and /dev/null differ diff --git a/e2e/__screenshots__/adventure-detail-dark.png b/e2e/__screenshots__/adventure-detail-dark.png deleted file mode 100644 index bac4d05e7..000000000 Binary files a/e2e/__screenshots__/adventure-detail-dark.png and /dev/null differ diff --git a/e2e/__screenshots__/adventure-detail-light.png b/e2e/__screenshots__/adventure-detail-light.png deleted file mode 100644 index 3bb82b474..000000000 Binary files a/e2e/__screenshots__/adventure-detail-light.png and /dev/null differ diff --git a/e2e/__screenshots__/adventures-dark.png b/e2e/__screenshots__/adventures-dark.png deleted file mode 100644 index 7b957dcad..000000000 Binary files a/e2e/__screenshots__/adventures-dark.png and /dev/null differ diff --git a/e2e/__screenshots__/adventures-light.png b/e2e/__screenshots__/adventures-light.png deleted file mode 100644 index c87762438..000000000 Binary files a/e2e/__screenshots__/adventures-light.png and /dev/null differ diff --git a/e2e/__screenshots__/challenge-detail-dark.png b/e2e/__screenshots__/challenge-detail-dark.png deleted file mode 100644 index 58c3e63c3..000000000 Binary files a/e2e/__screenshots__/challenge-detail-dark.png and /dev/null differ diff --git a/e2e/__screenshots__/challenge-detail-light.png b/e2e/__screenshots__/challenge-detail-light.png deleted file mode 100644 index 2800a3103..000000000 Binary files a/e2e/__screenshots__/challenge-detail-light.png and /dev/null differ diff --git a/e2e/__screenshots__/challenges-grid-dark.png b/e2e/__screenshots__/challenges-grid-dark.png deleted file mode 100644 index 761faae20..000000000 Binary files a/e2e/__screenshots__/challenges-grid-dark.png and /dev/null differ diff --git a/e2e/__screenshots__/challenges-grid-light.png b/e2e/__screenshots__/challenges-grid-light.png deleted file mode 100644 index e182c7d43..000000000 Binary files a/e2e/__screenshots__/challenges-grid-light.png and /dev/null differ diff --git a/e2e/__screenshots__/consent-banner-dark.png b/e2e/__screenshots__/consent-banner-dark.png deleted file mode 100644 index 0eea8afd9..000000000 Binary files a/e2e/__screenshots__/consent-banner-dark.png and /dev/null differ diff --git a/e2e/__screenshots__/consent-banner-light.png b/e2e/__screenshots__/consent-banner-light.png deleted file mode 100644 index 2cb75ad8c..000000000 Binary files a/e2e/__screenshots__/consent-banner-light.png and /dev/null differ diff --git a/e2e/__screenshots__/home-dark.png b/e2e/__screenshots__/home-dark.png deleted file mode 100644 index f24ef6e34..000000000 Binary files a/e2e/__screenshots__/home-dark.png and /dev/null differ diff --git a/e2e/__screenshots__/home-light.png b/e2e/__screenshots__/home-light.png deleted file mode 100644 index bc1a930bd..000000000 Binary files a/e2e/__screenshots__/home-light.png and /dev/null differ diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index 6fc10f20c..c352b31ca 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -1,41 +1,51 @@ -// Automates the manual accessibility checks from ACCESSIBILITY.md. -// Requires a production build in dist/client/. Run `npm run build` before `npm run test:e2e`. +// Accessibility audit. Requires a production build in +// dist/ (the webServer runs `astro preview`). +// +// Uses waitForLoadState("load") rather than "networkidle": prefetchAll keeps the +// network busy after load, so networkidle can hang. import { test, expect, type Page } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; +import { A11Y_PAGES as PAGES } from "./routes"; -// Representative routes covering each major layout type: -// home, challenges listing, adventure landing page, adventure detail, level detail, challenge tag. -const PAGES = [ - "/", - "/adventures", - "/challenges", - "/adventures/blind-by-design", - "/adventures/blind-by-design/levels/beginner", - "/challenges/opentelemetry", -]; +const AXE_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"]; -// --------------------------------------------------------------------------- -// Windows High Contrast Mode (forced colors) -// Activates the CSS `forced-colors: active` media feature so that -// @media (forced-colors: active) overrides in src/index.css are exercised. -// Verifies no axe rules are violated under this rendering mode, catching -// components that rely solely on background-color to communicate state. -// --------------------------------------------------------------------------- -test.describe("Windows High Contrast Mode: forced colors", () => { +test.describe("axe: dark mode", () => { for (const path of PAGES) { test(path, async ({ page }) => { - await page.emulateMedia({ reducedMotion: "reduce", forcedColors: "active" }); + await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto(path); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("load"); + const results = await new AxeBuilder({ page }).withTags(AXE_TAGS).analyze(); + expect(results.violations, `axe violations on ${path} (dark)`).toEqual([]); + }); + } +}); - // color-contrast is excluded: under Playwright's forced-colors emulation - // the CSS media query fires but computed colors are not remapped to system - // values, producing false positives. Real forced-colors rendering handles - // contrast automatically via the system color palette. +test.describe("axe: light mode", () => { + for (const path of PAGES) { + test(path, async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("theme", "light")); + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto(path); + await page.waitForLoadState("load"); + const results = await new AxeBuilder({ page }).withTags(AXE_TAGS).analyze(); + expect(results.violations, `axe violations on ${path} (light)`).toEqual([]); + }); + } +}); + +test.describe("axe: forced colors (Windows High Contrast)", () => { + for (const path of PAGES) { + test(path, async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce", forcedColors: "active" }); + await page.goto(path); + await page.waitForLoadState("load"); + // color-contrast excluded: forced-colors emulation fires the media query + // but doesn't remap computed colors, producing false positives. const results = await new AxeBuilder({ page }) - .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"]) + .withTags(AXE_TAGS) .disableRules(["color-contrast"]) .analyze(); expect(results.violations, `forced-colors axe violations on ${path}`).toEqual([]); @@ -43,19 +53,11 @@ test.describe("Windows High Contrast Mode: forced colors", () => { } }); -// --------------------------------------------------------------------------- -// Touch target minimum size (WCAG 2.5.8) -// Every interactive element visible in the viewport must have a bounding box -// of at least 24×24 CSS pixels. Skips elements fully outside the viewport -// (e.g. below-fold cards) since they are not currently reachable by touch. -// --------------------------------------------------------------------------- - test.describe("touch target minimum size (WCAG 2.5.8)", () => { for (const path of PAGES) { test(path, async ({ page }) => { await page.goto(path); - await page.waitForLoadState("networkidle"); - + await page.waitForLoadState("load"); const violations = await page.evaluate((): string[] => { const MIN = 24; return Array.from( @@ -64,36 +66,18 @@ test.describe("touch target minimum size (WCAG 2.5.8)", () => { ), ) .filter((el) => { - // WCAG 2.5.8 exempts inline targets within sentences whose size is - // constrained by the surrounding line-height of non-target text. - // display:inline is always an inline text run. if (window.getComputedStyle(el).display === "inline") return false; - - // Exempt links inside block-level prose containers. if (el.closest("p, td, th, dd, blockquote, figcaption")) return false; - - // Exempt links whose immediate parent has non-whitespace text node - // siblings. The link is inline within a sentence and its size is - // constrained by the surrounding line-height. This covers both the - // <p>text <a>...</a> text</p> pattern and the - // <span class="md-inline">text <a>...</a></span> pattern used when - // Markdown prose is wrapped in a span for CSS scoping. const parent = el.parentElement; if (parent) { const hasTextSiblings = Array.from(parent.childNodes).some( - (n) => - n.nodeType === Node.TEXT_NODE && - (n.textContent ?? "").trim().length > 0, + (n) => n.nodeType === Node.TEXT_NODE && (n.textContent ?? "").trim().length > 0, ); if (hasTextSiblings) return false; } - const r = el.getBoundingClientRect(); const inViewport = - r.bottom > 0 && - r.top < window.innerHeight && - r.right > 0 && - r.left < window.innerWidth; + r.bottom > 0 && r.top < window.innerHeight && r.right > 0 && r.left < window.innerWidth; return inViewport && r.width > 0 && r.height > 0 && (r.width < MIN || r.height < MIN); }) .map((el) => { @@ -101,42 +85,25 @@ test.describe("touch target minimum size (WCAG 2.5.8)", () => { return `${Math.round(width)}×${Math.round(height)}px: ${el.outerHTML.slice(0, 100)}`; }); }); - - expect( - violations, - `Interactive elements below 24×24px (WCAG 2.5.8) on ${path}`, - ).toHaveLength(0); + expect(violations, `Interactive elements below 24×24px on ${path}`).toHaveLength(0); }); } }); -// --------------------------------------------------------------------------- -// Focus ring visibility -// Tabs through all keyboard-reachable elements and asserts that each one has -// a visible focus indicator. The pattern in this codebase is Tailwind ring -// utilities, which produce a box-shadow; outline-width > 0 is also accepted -// for browser-default or custom outline styles. Stops when focus cycles back -// to the first focused element (full traversal complete). -// Runs in both dark and light mode because ring colors differ between modes. -// --------------------------------------------------------------------------- - const MAX_TABS = 200; async function collectFocusViolations(page: Page): Promise<string[]> { let firstKey: string | null = null; + const seenKeys = new Set<string>(); const violations: string[] = []; - for (let i = 0; i < MAX_TABS; i++) { await page.keyboard.press("Tab"); - const result = await page.evaluate(() => { const el = document.activeElement as HTMLElement; if (!el || el.tagName === "BODY" || el === document.documentElement) return null; - const cs = window.getComputedStyle(el); const hasBoxShadow = cs.boxShadow !== "none" && cs.boxShadow !== ""; const hasOutline = parseFloat(cs.outlineWidth) > 0 && cs.outlineStyle !== "none"; - const key = [ el.tagName, el.id ?? "", @@ -144,27 +111,20 @@ async function collectFocusViolations(page: Page): Promise<string[]> { el.getAttribute("aria-label") ?? "", (el.textContent ?? "").trim().slice(0, 40), ].join("|"); - - return { - key, - hasFocusRing: hasBoxShadow || hasOutline, - html: el.outerHTML.slice(0, 120), - }; + return { key, hasFocusRing: hasBoxShadow || hasOutline, html: el.outerHTML.slice(0, 120) }; }); - if (!result) break; - if (firstKey === null) { firstKey = result.key; - } else if (result.key === firstKey) { - break; // Completed a full cycle. - } - - if (!result.hasFocusRing) { - violations.push(result.html); + } else if (result.key === firstKey && seenKeys.size > 1) { + // Full cycle: we've seen other elements between this and the start. + // Requiring seenKeys.size > 1 prevents identical adjacent buttons (same key) + // from triggering an early exit before the real cycle completes. + break; } + seenKeys.add(result.key); + if (!result.hasFocusRing) violations.push(result.html); } - return violations; } @@ -173,7 +133,7 @@ test.describe("focus ring visibility: dark mode", () => { test(path, async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto(path); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("load"); const violations = await collectFocusViolations(page); expect(violations, `Elements missing focus ring on ${path} (dark)`).toHaveLength(0); }); @@ -186,82 +146,80 @@ test.describe("focus ring visibility: light mode", () => { await page.addInitScript(() => localStorage.setItem("theme", "light")); await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto(path); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("load"); const violations = await collectFocusViolations(page); expect(violations, `Elements missing focus ring on ${path} (light)`).toHaveLength(0); }); } }); -// --------------------------------------------------------------------------- -// Keyboard focus trap detection -// Tabs through the page and fails if the same element receives keyboard focus -// on two consecutive keypresses, the signature of a focus trap where Tab -// cannot move focus forward. Normal focus cycling (returning to the first -// element after the last) is not a trap and is detected by the key change. -// --------------------------------------------------------------------------- - -test.describe("keyboard focus trap detection", () => { +test.describe("200% zoom: no horizontal overflow", () => { for (const path of PAGES) { test(path, async ({ page }) => { + await page.setViewportSize({ width: 384, height: 768 }); await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto(path); - await page.waitForLoadState("networkidle"); - - let previousKey: string | null = null; - - for (let i = 0; i < MAX_TABS; i++) { - await page.keyboard.press("Tab"); - - const key = await page.evaluate((): string | null => { - const el = document.activeElement as HTMLElement; - if (!el || el.tagName === "BODY") return null; - return [ - el.tagName, - el.id ?? "", - el.getAttribute("href") ?? "", - el.getAttribute("aria-label") ?? "", - (el.textContent ?? "").trim().slice(0, 40), - ].join("|"); - }); + await page.waitForLoadState("load"); + const hasOverflow = await page.evaluate( + () => document.documentElement.scrollWidth > window.innerWidth, + ); + expect(hasOverflow, `Horizontal overflow at 384px viewport on ${path}`).toBe(false); + }); + } +}); - if (!key) break; +// WCAG 2.4.1 Bypass Blocks. The skip link is the first thing a keyboard user +// meets on every page; if it stops working, every page becomes a full tab +// crawl through the nav. Checked on a representative sample rather than all +// routes, since the link lives in the shared layout. +test.describe("skip link (WCAG 2.4.1)", () => { + for (const path of ["/", "/adventures/", "/challenges/", "/handbook/"]) { + test(`${path}: is the first Tab stop and moves focus into main`, async ({ page }) => { + await page.goto(path); + await page.waitForLoadState("load"); - expect( - key, - `Focus trap on ${path}: Tab did not move focus away from "${key}"`, - ).not.toBe(previousKey); + await page.keyboard.press("Tab"); + await expect(page.locator(":focus")).toContainText("Skip to main content"); - previousKey = key; - } + await page.keyboard.press("Enter"); + await expect(page.locator(":focus")).toHaveAttribute("id", "main-content"); }); } }); -// --------------------------------------------------------------------------- -// 200% zoom, no horizontal overflow -// A 384px viewport approximates the layout effect of 200% browser zoom on a -// 768px screen (the tablet breakpoint). Horizontal scrollbar presence at -// this width means content overflows its container and will clip or require -// sideways scrolling at high zoom levels. -// --------------------------------------------------------------------------- +// WAVE flags text under 10px as "very small text". The gate is set at that line +// rather than at the type scale minimum (text-xs, 12px), because inline <code> +// renders at 11.9px by design and would otherwise fail every prose page. +// +// Hidden elements are included deliberately: the avatar initials chips that +// prompted this are display:none while the image loads and become visible the +// moment it fails, so "currently hidden" is not a defence. +test.describe("no very small text", () => { + const MIN_PX = 10; -test.describe("200% zoom: no horizontal overflow", () => { for (const path of PAGES) { test(path, async ({ page }) => { - await page.setViewportSize({ width: 384, height: 768 }); - await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto(path); - await page.waitForLoadState("networkidle"); - - const hasOverflow = await page.evaluate( - () => document.documentElement.scrollWidth > window.innerWidth, - ); + await page.waitForLoadState("load"); + + const tooSmall = await page.evaluate((min) => { + const out: string[] = []; + document.querySelectorAll<HTMLElement>("body *").forEach((el) => { + const own = Array.from(el.childNodes) + .filter((n) => n.nodeType === 3) + .map((n) => (n.textContent ?? "").trim()) + .join(" ") + .trim(); + if (!own) return; + const px = parseFloat(getComputedStyle(el).fontSize); + if (px < min) { + out.push(`${px}px <${el.tagName.toLowerCase()}> "${own.slice(0, 30)}"`); + } + }); + return [...new Set(out)]; + }, MIN_PX); - expect( - hasOverflow, - `Horizontal overflow at 384px viewport (200% zoom equivalent) on ${path}`, - ).toBe(false); + expect(tooSmall, `text below ${MIN_PX}px on ${path}`).toEqual([]); }); } }); diff --git a/e2e/avatar-fallback.spec.ts b/e2e/avatar-fallback.spec.ts new file mode 100644 index 000000000..4eee1e0dd --- /dev/null +++ b/e2e/avatar-fallback.spec.ts @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2025 OffOn contributors +// SPDX-License-Identifier: MIT + +// Community avatars are external (Discourse) and go stale when a user changes +// theirs. Every one must degrade to an initials chip rather than a broken-image +// icon, on both surfaces that render them. + +import { test, expect } from "@playwright/test"; + +const PAGES = [ + { path: "/adventures/dead-reckoning/", what: "adventure leaderboard" }, + { path: "/adventures/echoes-lost-in-orbit/levels/beginner/", what: "challenge sidebar" }, + { path: "/about/", what: "community leaders" }, +]; + +for (const { path, what } of PAGES) { + test(`${what}: avatars fall back to initials when the image fails`, async ({ page }) => { + await page.route("**/community.offon.dev/**", (r) => r.abort()); + await page.route("**/*discourse-cdn.com/**", (r) => r.abort()); + await page.goto(path); + await page.waitForLoadState("load"); + + // Avatars are loading="lazy". An image that was never requested is pending, + // not failed, and never fires onerror, so scroll it into view first. + await page.evaluate(async () => { + for (let y = 0; y < document.body.scrollHeight; y += window.innerHeight) { + window.scrollTo(0, y); + await new Promise((r) => setTimeout(r, 60)); + } + }); + await page.waitForFunction(() => + Array.from(document.querySelectorAll<HTMLImageElement>("img")) + .filter((i) => /community\.offon\.dev|discourse-cdn/.test(i.src)) + .every((i) => i.complete), + ); + + const state = await page.evaluate(() => { + const imgs = Array.from(document.querySelectorAll<HTMLImageElement>("img")).filter((i) => + /community\.offon\.dev|discourse-cdn/.test(i.src), + ); + const chips = Array.from(document.querySelectorAll<HTMLElement>("span")).filter( + (el) => /rounded-full/.test(el.className) && /^[A-Z0-9]{1,2}$/.test((el.textContent ?? "").trim()), + ); + return { + // Failed means the load was attempted and produced nothing. A displayed + // image that loaded fine is not a failure, and a lazy image that was + // never requested is pending rather than broken. + failedButShown: imgs.filter( + (i) => i.complete && i.naturalWidth === 0 && getComputedStyle(i).display !== "none", + ).length, + visibleChips: chips.filter((c) => getComputedStyle(c).display !== "none").length, + }; + }); + + expect(state.failedButShown, "a failed avatar must not stay displayed").toBe(0); + expect(state.visibleChips, "an initials chip must take its place").toBeGreaterThan(0); + }); +} + +test("chips are never below the very-small-text threshold", async ({ page }) => { + await page.route("**/community.offon.dev/**", (r) => r.abort()); + await page.goto("/adventures/dead-reckoning/"); + await page.waitForLoadState("load"); + const sizes = await page.evaluate(() => + Array.from(document.querySelectorAll<HTMLElement>("span")) + .filter((el) => /rounded-full/.test(el.className) && /^[A-Z0-9]{1,2}$/.test((el.textContent ?? "").trim())) + .map((el) => parseFloat(getComputedStyle(el).fontSize)), + ); + expect(sizes.length).toBeGreaterThan(0); + for (const px of sizes) expect(px).toBeGreaterThanOrEqual(10); +}); diff --git a/e2e/brand-toc.spec.ts b/e2e/brand-toc.spec.ts new file mode 100644 index 000000000..836ed71c7 --- /dev/null +++ b/e2e/brand-toc.spec.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2025 OffOn contributors +// SPDX-License-Identifier: MIT +// Brand page table-of-contents scrollspy: the link for the section currently in +// the top band of the viewport must carry aria-current="location" and the active +// border, and only ever one link at a time. +import { test, expect } from "@playwright/test"; + +const current = (page: import("@playwright/test").Page) => + page.evaluate(() => { + const el = document.querySelector('[data-toc-link][aria-current="location"]') as HTMLElement | null; + return el ? { id: el.dataset.tocLink, hasActiveBorder: el.classList.contains("border-primary") } : null; + }); + +test("brand TOC scrollspy tracks the section in view", async ({ page }) => { + await page.setViewportSize({ width: 1400, height: 900 }); + await page.goto("/brand/"); + await page.waitForFunction(() => !!document.querySelector("[data-toc]")); + + expect(await current(page)).toEqual({ id: "mission", hasActiveBorder: true }); + + for (const id of ["typography", "voice", "accessibility"]) { + await page.evaluate((i) => document.getElementById(i)!.scrollIntoView({ block: "start" }), id); + await page.waitForFunction( + (i) => document.querySelector('[data-toc-link][aria-current="location"]')?.getAttribute("data-toc-link") === i, + id, + { timeout: 4000 }, + ); + expect(await current(page)).toEqual({ id, hasActiveBorder: true }); + } + + // Exactly one link is ever marked current. + const n = await page.locator('[data-toc-link][aria-current="location"]').count(); + expect(n).toBe(1); +}); diff --git a/e2e/btn-primary-contrast.spec.ts b/e2e/btn-primary-contrast.spec.ts new file mode 100644 index 000000000..d00dd6387 --- /dev/null +++ b/e2e/btn-primary-contrast.spec.ts @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2025 OffOn contributors +// SPDX-License-Identifier: MIT + +// WCAG 1.4.11 for the primary button, on every surface it appears on. +// +// The amber fill is only ~1.6:1 against the near-white surfaces in light mode, +// so the control is identified by its border there. `.btn-primary` is used +// site-wide, so this walks every route rather than one page. + +import { test, expect, type Page } from "@playwright/test"; +import { SMOKE_ROUTES } from "./routes"; + +const MIN_BOUNDARY = 3; +const MIN_LABEL = 4.5; + +function lum(rgb: string): number { + const [r, g, b] = rgb.match(/[\d.]+/g)!.slice(0, 3).map(Number); + const f = (c: number): number => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }; + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); +} +function ratio(a: string, b: string): number { + const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); + return (x + 0.05) / (y + 0.05); +} + +/** Fill, border and the nearest opaque backdrop for every .btn-primary on the page. */ +async function samples(page: Page): Promise<{ fill: string; border: string; backdrop: string }[]> { + return page.evaluate(() => + Array.from(document.querySelectorAll<HTMLElement>(".btn-primary")).map((el) => { + const cs = getComputedStyle(el); + // Walk to the nearest ancestor with an opaque background. A translucent + // one (the consent banner) is skipped: what the eye compares against is + // the opaque surface behind it. + let p: HTMLElement | null = el.parentElement; + let backdrop = getComputedStyle(document.body).backgroundColor; + while (p) { + const bg = getComputedStyle(p).backgroundColor; + if (/^rgb\(/.test(bg)) { + backdrop = bg; + break; + } + p = p.parentElement; + } + return { fill: cs.backgroundColor, border: cs.borderTopColor, backdrop }; + }), + ); +} + +for (const theme of ["light", "dark"] as const) { + test.describe(`${theme} mode`, () => { + for (const path of Object.keys(SMOKE_ROUTES)) { + test(`${path}: primary buttons are identifiable`, async ({ page }) => { + await page.addInitScript((t) => localStorage.setItem("theme", t), theme); + await page.goto(path); + await page.waitForLoadState("load"); + + const found = await samples(page); + for (const { fill, border, backdrop } of found) { + // Either the fill or its border must separate the control from what + // is behind it. + const boundary = Math.max(ratio(fill, backdrop), ratio(border, backdrop)); + expect( + boundary, + `${path} (${theme}): fill ${fill} / border ${border} on ${backdrop}`, + ).toBeGreaterThanOrEqual(MIN_BOUNDARY); + } + }); + } + }); +} + +test("label contrast holds in both themes", async ({ page }) => { + for (const theme of ["light", "dark"] as const) { + await page.addInitScript((t) => localStorage.setItem("theme", t), theme); + await page.goto("/"); + await page.waitForLoadState("load"); + const rows = await page.evaluate(() => + Array.from(document.querySelectorAll<HTMLElement>(".btn-primary")).map((el) => { + const cs = getComputedStyle(el); + return { fg: cs.color, bg: cs.backgroundColor }; + }), + ); + for (const { fg, bg } of rows) { + expect(ratio(fg, bg), `${theme}: label ${fg} on ${bg}`).toBeGreaterThanOrEqual(MIN_LABEL); + } + } +}); + +test("adding the border did not change button height", async ({ page }) => { + await page.goto("/"); + const heights = await page.evaluate(() => + [".btn-primary", ".btn-ghost", ".btn-secondary"].map((sel) => { + const el = document.querySelector(sel) as HTMLElement | null; + return el ? Math.round(el.getBoundingClientRect().height) : null; + }), + ); + const present = heights.filter((h): h is number => h !== null); + expect(new Set(present).size, `button heights differ: ${JSON.stringify(heights)}`).toBe(1); +}); diff --git a/e2e/budget.spec.ts b/e2e/budget.spec.ts new file mode 100644 index 000000000..1e7311b1a --- /dev/null +++ b/e2e/budget.spec.ts @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: 2025 OffOn contributors +// SPDX-License-Identifier: MIT + +// Page weight, third-party requests, image hygiene and media autoplay. +// These are the only automated guards against a dependency or an unoptimised +// asset quietly inflating the site, and against anything phoning home before +// the visitor has consented. +// +// Requires a production build in dist/ (the Playwright webServer runs +// `astro preview`). + +import { test, expect } from "@playwright/test"; + +const PAGES = [ + "/", + "/adventures/", + "/challenges/", + // Representative detail pages: content-heavy routes where weight regresses first. + "/adventures/blind-by-design/levels/beginner/", + "/challenges/opentelemetry/", +]; + +// Total compressed bytes transferred on first load, no cache. +// On a failure the message reports the actual figure, so raise this only +// deliberately and with a reason. +const PAGE_WEIGHT_BUDGET_KB = 750; + +// Hosts allowed to receive requests on load. First-party content only: +// never analytics, ads or tracking. Kept in step with the CSP img-src in +// Layout.astro. +const ALLOWED_EXTERNAL_HOSTS = [ + "community.offon.dev", + "avatars.discourse-cdn.com", + "sea2.discourse-cdn.com", +]; + +function isAllowedHost(hostname: string): boolean { + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + ALLOWED_EXTERNAL_HOSTS.some((h) => hostname === h || hostname.endsWith(`.${h}`)) + ); +} + +test.describe("page weight", () => { + for (const path of PAGES) { + test(`${path} total transfer < ${PAGE_WEIGHT_BUDGET_KB} KB`, async ({ page, context }) => { + const client = await context.newCDPSession(page); + await client.send("Network.enable"); + + let totalBytes = 0; + client.on("Network.loadingFinished", (event) => { + totalBytes += event.encodedDataLength; + }); + + await page.goto(path); + await page.waitForLoadState("networkidle"); + + const kb = Math.round(totalBytes / 1024); + expect( + totalBytes, + `${path} transferred ${kb} KB, over the ${PAGE_WEIGHT_BUDGET_KB} KB budget`, + ).toBeLessThan(PAGE_WEIGHT_BUDGET_KB * 1024); + }); + } +}); + +test.describe("third-party requests", () => { + for (const path of PAGES) { + test(`${path} contacts no host outside the allowlist before consent`, async ({ page }) => { + const unexpected: string[] = []; + + page.on("request", (request) => { + try { + const { hostname } = new URL(request.url()); + if (!isAllowedHost(hostname)) unexpected.push(request.url()); + } catch { + // non-http scheme (data:, blob:), not a network request + } + }); + + await page.goto(path); + await page.waitForLoadState("networkidle"); + + expect(unexpected, `${path} made unexpected third-party requests`).toHaveLength(0); + }); + } +}); + +test.describe("image hygiene", () => { + for (const path of PAGES) { + test(`${path}: every image declares width and height`, async ({ page }) => { + await page.goto(path); + await page.waitForLoadState("networkidle"); + + const violations = await page.evaluate((): string[] => + Array.from(document.querySelectorAll("img")) + .filter((img) => !img.hasAttribute("width") || !img.hasAttribute("height")) + .map((img) => img.outerHTML.slice(0, 120)), + ); + + expect(violations, "images missing explicit width/height (causes CLS)").toHaveLength(0); + }); + + test(`${path}: below-fold images are lazy`, async ({ page }) => { + await page.goto(path); + await page.waitForLoadState("networkidle"); + + const violations = await page.evaluate((): string[] => + Array.from(document.querySelectorAll("img")) + .filter((img) => img.getBoundingClientRect().top >= window.innerHeight && img.loading !== "lazy") + .map((img) => img.outerHTML.slice(0, 120)), + ); + + expect(violations, 'below-fold images missing loading="lazy"').toHaveLength(0); + }); + + test(`${path}: no unmuted autoplaying media`, async ({ page }) => { + await page.goto(path); + await page.waitForLoadState("networkidle"); + + const violations = await page.evaluate((): string[] => + Array.from(document.querySelectorAll("video[autoplay], audio[autoplay]")) + .filter((el) => !el.hasAttribute("muted")) + .map((el) => el.outerHTML.slice(0, 120)), + ); + + expect(violations, "autoplaying media without muted").toHaveLength(0); + }); + } +}); diff --git a/e2e/challenges-filter-deep.spec.ts b/e2e/challenges-filter-deep.spec.ts new file mode 100644 index 000000000..cc48ac9d7 --- /dev/null +++ b/e2e/challenges-filter-deep.spec.ts @@ -0,0 +1,264 @@ +// SPDX-FileCopyrightText: 2025 OffOn contributors +// SPDX-License-Identifier: MIT + +// Behaviours the Vue component's unit tests covered, retargeted at the rendered +// page: SSR-correct filtered state on a tag route, widening past that tag, URL +// reconciliation in both directions, the empty state, and live-region timing. +// +import { test, expect, type Page } from "@playwright/test"; +test.use({ viewport: { width: 1400, height: 900 } }); + +const cards = (p: Page) => p.locator("[data-level-card]:not([hidden])"); +const advGrid = (p: Page) => p.locator('[data-results="adventures"]'); +const count = (p: Page) => p.locator("[data-count]"); +const live = (p: Page) => p.locator("[data-live-count]"); +const url = (p: Page) => new URL(p.url()).pathname + new URL(p.url()).search; + +test("tag route SSR shows only matching cards, with JS disabled", async ({ browser }) => { + const ctx = await browser.newContext({ javaScriptEnabled: false }); + const p = await ctx.newPage(); + await p.goto("/challenges/kubernetes/"); + expect(await cards(p).count()).toBe(3); + await expect(advGrid(p)).toBeHidden(); + await expect(count(p)).toHaveText("3 challenges · Kubernetes"); + await ctx.close(); +}); + +test("widening with All Tools shows everything and drops the path segment", async ({ page }) => { + await page.goto("/challenges/kubernetes/"); + expect(await cards(page).count()).toBe(3); + await page.getByRole("button", { name: "All Tools" }).first().click(); + // No filters: the level grid is hidden as a whole and the adventure grid returns. + await expect(page.locator('[data-results="levels"]')).toBeHidden(); + await expect(advGrid(page)).toBeVisible(); + expect(url(page)).toBe("/challenges/"); +}); + +test("adding a second tag widens and syncs both into ?topics", async ({ page }) => { + await page.goto("/challenges/kubernetes/"); + const before = await cards(page).count(); + await page.getByRole("button", { name: "Backstage", exact: true }).first().click(); + const after = await cards(page).count(); + expect(after).toBeGreaterThan(before); + expect(url(page)).toContain("topics=kubernetes%2Cbackstage"); +}); + +test("restores ?topics and ?difficulty on load", async ({ page }) => { + await page.goto("/challenges/?topics=kubernetes&difficulty=Expert"); + await page.waitForFunction(() => !document.querySelector('[data-results="levels"]')?.hasAttribute("hidden")); + const shown = await cards(page).count(); + expect(shown).toBeGreaterThan(0); + for (const c of await cards(page).all()) { + expect(await c.getAttribute("data-difficulty")).toBe("Expert"); + expect(await c.getAttribute("data-tags")).toContain("kubernetes"); + } + await expect(count(page)).toHaveText(/Expert/); +}); + +test("empty state appears when nothing matches", async ({ page }) => { + await page.goto("/challenges/?topics=kubernetes&difficulty=Beginner"); + await page.waitForLoadState("load"); + const shown = await cards(page).count(); + const empty = page.locator("[data-empty]"); + if (shown === 0) await expect(empty).toBeVisible(); + else await expect(empty).toBeHidden(); + console.log(" kubernetes+Beginner matches:", shown); +}); + +test("live region is silent on load and speaks after a change", async ({ page }) => { + await page.goto("/challenges/kubernetes/"); + await expect(live(page)).toHaveText(""); + await page.getByRole("radio", { name: "Expert" }).click(); + await expect(live(page)).toHaveText(/Showing \d+ challenge/); + + // Clearing the tags while a difficulty is still set is not "filters cleared". + await page.getByRole("button", { name: "All Tools" }).first().click(); + await expect(live(page)).toHaveText(/Showing .*Expert/); + + await page.getByRole("radio", { name: "All Levels" }).click(); + await expect(live(page)).toHaveText(/Filters cleared/); +}); + +test("difficulty toggles off when reselected", async ({ page }) => { + await page.goto("/challenges/"); + await page.getByRole("radio", { name: "Expert" }).click(); + expect(url(page)).toContain("difficulty=Expert"); + await page.getByRole("radio", { name: "Expert" }).click(); + expect(url(page)).not.toContain("difficulty="); +}); + +test("home swaps the adventure grid for results when filtered", async ({ page }) => { + await page.goto("/"); + await expect(advGrid(page)).toBeVisible(); + await expect(page.locator('[data-results="levels"]')).toBeHidden(); + + await page.getByRole("radio", { name: "Expert" }).click(); + await expect(advGrid(page)).toBeHidden(); + await expect(page.locator('[data-results="levels"]')).toBeVisible(); +}); + +// ── ARIA contract ─────────────────────────────────────────────────────────── +// Structural assertions the unit tests used to make against the mounted +// component. Cheap here, and they now check the shipped markup rather than a +// render tree. + +test.describe("aria contract", () => { + test("desktop difficulty controls form a labelled radiogroup", async ({ page }) => { + await page.goto("/challenges/"); + const group = page.getByRole("radiogroup", { name: "Filter by difficulty" }); + await expect(group).toBeVisible(); + + const radios = group.getByRole("radio"); + // All Levels plus one per difficulty. + await expect(radios).toHaveCount(4); + await expect(radios.first()).toHaveAccessibleName("All Levels"); + + // Exactly one checked, and the values are the strings ARIA requires. + await expect(group.locator('[aria-checked="true"]')).toHaveCount(1); + await expect(radios.first()).toHaveAttribute("aria-checked", "true"); + await expect(radios.nth(1)).toHaveAttribute("aria-checked", "false"); + }); + + test("desktop technology controls form a labelled group of toggles", async ({ page }) => { + await page.goto("/challenges/"); + const group = page.getByRole("group", { name: "Filter by technology" }); + await expect(group).toBeVisible(); + await expect(group.getByRole("button", { name: "All Tools" })).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + test("the live region is polite and atomic", async ({ page }) => { + await page.goto("/challenges/"); + const live = page.locator("[data-live-count]"); + await expect(live).toHaveAttribute("aria-live", "polite"); + await expect(live).toHaveAttribute("aria-atomic", "true"); + }); + + test("selecting a second difficulty deselects the first", async ({ page }) => { + await page.goto("/challenges/"); + const group = page.getByRole("radiogroup", { name: "Filter by difficulty" }); + await page.getByRole("radio", { name: "Beginner" }).click(); + await expect(page.getByRole("radio", { name: "Beginner" })).toHaveAttribute("aria-checked", "true"); + + await page.getByRole("radio", { name: "Expert" }).click(); + await expect(page.getByRole("radio", { name: "Beginner" })).toHaveAttribute("aria-checked", "false"); + await expect(group.locator('[aria-checked="true"]')).toHaveCount(1); + }); + + test("All Levels clears the difficulty selection", async ({ page }) => { + await page.goto("/challenges/"); + await page.getByRole("radio", { name: "Expert" }).click(); + await page.getByRole("radio", { name: "All Levels" }).click(); + await expect(page.getByRole("radio", { name: "All Levels" })).toHaveAttribute("aria-checked", "true"); + expect(url(page)).not.toContain("difficulty="); + }); + + test("tag pills toggle aria-pressed both ways", async ({ page }) => { + await page.goto("/challenges/"); + const pill = page.getByRole("button", { name: "Kubernetes", exact: true }).first(); + await expect(pill).toHaveAttribute("aria-pressed", "false"); + await pill.click(); + await expect(pill).toHaveAttribute("aria-pressed", "true"); + await pill.click(); + await expect(pill).toHaveAttribute("aria-pressed", "false"); + }); + + test("the sr-only results heading is suppressed on home and present on /challenges/", async ({ + page, + }) => { + // Home already has a visible "Choose Your Adventure" heading; a second one + // would duplicate the document outline. + await page.goto("/"); + await expect(page.locator("[data-results-heading]")).toHaveCount(0); + + await page.goto("/challenges/"); + await expect(page.locator("[data-results-heading]")).toHaveText("All Challenges"); + await page.getByRole("radio", { name: "Expert" }).click(); + await expect(page.locator("[data-results-heading]")).toHaveText("Filtered Challenges"); + }); + + test("the See all link appears only when the page previews fewer adventures than exist", async ({ + page, + }) => { + await page.goto("/adventures/"); + const total = await page.locator('a.card-glow[href*="/adventures/"]').count(); + + await page.goto("/"); + const previewed = await page.locator('[data-results="adventures"] a.card-glow').count(); + const link = page.locator("[data-see-all]"); + + if (total > previewed) await expect(link).toBeVisible(); + else await expect(link).toHaveCount(0); + }); +}); + +// ── mobile dropdowns ──────────────────────────────────────────────────────── + +test.describe("mobile dropdowns", () => { + test.use({ viewport: { width: 800, height: 900 } }); + + test("triggers are wired to their panels and start closed", async ({ page }) => { + await page.goto("/challenges/"); + for (const id of ["difficulty-group", "tags-group"]) { + const trigger = page.locator(`[aria-controls="${id}"]`); + await expect(trigger).toHaveAttribute("aria-expanded", "false"); + await expect(page.locator(`#${id}`)).toBeHidden(); + } + }); + + test("opening one dropdown closes the other", async ({ page }) => { + await page.goto("/challenges/"); + await page.locator('[aria-controls="difficulty-group"]').click(); + await expect(page.locator("#difficulty-group")).toBeVisible(); + + await page.locator('[aria-controls="tags-group"]').click(); + await expect(page.locator("#tags-group")).toBeVisible(); + await expect(page.locator("#difficulty-group")).toBeHidden(); + }); + + test("a choice made in the dropdown is reflected in the desktop controls", async ({ page }) => { + await page.goto("/challenges/"); + await page.locator('[aria-controls="difficulty-group"]').click(); + await page.locator('#difficulty-group [data-difficulty-option="Expert"]').click(); + + await expect(page.locator('[aria-controls="difficulty-group"]')).toHaveAccessibleName( + "Filter by difficulty: Expert", + ); + + // Both breakpoints read the same state, so the desktop radio agrees. + await page.setViewportSize({ width: 1400, height: 900 }); + await expect(page.getByRole("radio", { name: "Expert" })).toHaveAttribute("aria-checked", "true"); + }); +}); + +test.describe("keyboard edge cases", () => { + test("non-arrow keys do not move the radiogroup selection", async ({ page }) => { + await page.goto("/challenges/"); + const allLevels = page.getByRole("radio", { name: "All Levels" }); + await allLevels.focus(); + + for (const key of ["Enter", "Home", "End", "a", "Escape"]) { + await page.keyboard.press(key); + } + await expect(allLevels).toHaveAttribute("aria-checked", "true"); + expect(url(page)).not.toContain("difficulty="); + }); +}); + +test.describe("dropdown panels are labelled groups", () => { + test.use({ viewport: { width: 800, height: 900 } }); + + for (const [id, label] of [ + ["difficulty-group", "Filter by difficulty"], + ["tags-group", "Filter by technology"], + ] as const) { + test(`#${id} is role=group labelled "${label}"`, async ({ page }) => { + await page.goto("/challenges/"); + const panel = page.locator(`#${id}`); + await expect(panel).toHaveAttribute("role", "group"); + await expect(panel).toHaveAttribute("aria-label", label); + }); + } +}); diff --git a/e2e/challenges-filter.spec.ts b/e2e/challenges-filter.spec.ts new file mode 100644 index 000000000..fff99d027 --- /dev/null +++ b/e2e/challenges-filter.spec.ts @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: 2025 OffOn contributors +// SPDX-License-Identifier: MIT +// ChallengesFilter keyboard and dismissal behaviour. +// +// The filter has two distinct UIs at different breakpoints, so each group sets +// its own viewport: dropdowns below 1024px, the pill radiogroup at and above it. +import { test, expect, type Page } from "@playwright/test"; + +const DIFF_TRIGGER = 'button[aria-controls="difficulty-group"]'; +const DIFF_PANEL = "#difficulty-group"; +const TAGS_TRIGGER = 'button[aria-controls="tags-group"]'; + +/** Settle consent so the banner is not an extra focus stop in these tests. */ +async function gotoChallenges(page: Page, waitFor: string): Promise<void> { + await page.addInitScript(() => + localStorage.setItem("analytics_consent", JSON.stringify({ value: "denied", timestamp: Date.now() })), + ); + await page.goto("/challenges/"); + await page.waitForSelector(waitFor); +} + +// An open panel must close on Escape, on an outside click, and when focus leaves +// it. Closing on focus-out must not pull focus back to the trigger, since the +// user has already tabbed somewhere else. +test.describe("dropdown dismissal", () => { + test.use({ viewport: { width: 800, height: 900 } }); + + test.beforeEach(async ({ page }) => { + await gotoChallenges(page, DIFF_TRIGGER); + }); + + test("tabbing out of an open panel closes it without stealing focus", async ({ page }) => { + await page.click(DIFF_TRIGGER); + await expect(page.locator(DIFF_PANEL)).toBeVisible(); + await expect(page.locator(DIFF_TRIGGER)).toHaveAttribute("aria-expanded", "true"); + + // Walk forward until focus leaves the difficulty wrapper. + for (let i = 0; i < 12; i++) { + await page.keyboard.press("Tab"); + const inside = await page.evaluate( + () => !!document.activeElement?.closest("#difficulty-group, [aria-controls='difficulty-group']"), + ); + if (!inside) break; + } + + await expect(page.locator(DIFF_PANEL)).toBeHidden(); + await expect(page.locator(DIFF_TRIGGER)).toHaveAttribute("aria-expanded", "false"); + + // Focus must have moved on, NOT snapped back to the trigger. + const onTrigger = await page.evaluate( + () => document.activeElement?.getAttribute("aria-controls") === "difficulty-group", + ); + expect(onTrigger, "focus-out must not restore focus to the trigger").toBe(false); + const activeTag = await page.evaluate(() => document.activeElement?.tagName); + expect(activeTag).not.toBe("BODY"); + }); + + test("keeps the panel open while focus stays inside it", async ({ page }) => { + await page.click(DIFF_TRIGGER); + await expect(page.locator(DIFF_PANEL)).toBeVisible(); + + await page.keyboard.press("Tab"); // into the first panel option + const inside = await page.evaluate(() => !!document.activeElement?.closest("#difficulty-group")); + expect(inside).toBe(true); + await expect(page.locator(DIFF_PANEL)).toBeVisible(); + }); + + test("Escape still closes and returns focus to the trigger", async ({ page }) => { + await page.click(DIFF_TRIGGER); + await expect(page.locator(DIFF_PANEL)).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(page.locator(DIFF_PANEL)).toBeHidden(); + await expect(page.locator(DIFF_TRIGGER)).toBeFocused(); + }); + + test("outside mousedown still closes the panel", async ({ page }) => { + await page.click(TAGS_TRIGGER); + await expect(page.locator("#tags-group")).toBeVisible(); + + await page.locator("h1").click(); + await expect(page.locator("#tags-group")).toBeHidden(); + }); +}); + +// Desktop difficulty radiogroup: APG roving tabindex. Arrow keys move focus and +// select, exactly one radio is tabbable at a time, and wrapping is circular. +// The group element itself must stay out of the tab order. +test.describe("difficulty radiogroup keyboard", () => { + test.use({ viewport: { width: 1400, height: 900 } }); + + const GROUP = '[role="radiogroup"][aria-label="Filter by difficulty"]'; + const RADIOS = `${GROUP} [role="radio"]`; + + test("arrow keys move focus, select, and wrap", async ({ page }) => { + await gotoChallenges(page, GROUP); + + const labels = await page.locator(RADIOS).allInnerTexts(); + expect(labels.length).toBeGreaterThan(2); + + const focused = () => page.evaluate(() => document.activeElement?.textContent?.trim() ?? ""); + const checked = () => + page.evaluate((sel) => { + const el = document.querySelector(`${sel}[aria-checked="true"]`); + return el?.textContent?.trim() ?? null; + }, RADIOS); + + await page.locator(RADIOS).first().focus(); + expect(await focused()).toBe(labels[0].trim()); + + await page.keyboard.press("ArrowRight"); + expect(await focused()).toBe(labels[1].trim()); + expect(await checked()).toBe(labels[1].trim()); + + await page.keyboard.press("ArrowLeft"); + expect(await focused()).toBe(labels[0].trim()); + + // Wrap backwards from the first to the last. + await page.keyboard.press("ArrowLeft"); + expect(await focused()).toBe(labels[labels.length - 1].trim()); + + // Wrap forwards from the last back to the first. + await page.keyboard.press("ArrowRight"); + expect(await focused()).toBe(labels[0].trim()); + }); + + test("exactly one radio is tabbable and the group is not", async ({ page }) => { + await gotoChallenges(page, GROUP); + + const state = await page.evaluate((sel) => { + const group = document.querySelector(sel.group) as HTMLElement; + const radios = Array.from(document.querySelectorAll<HTMLElement>(sel.radios)); + return { + groupTabindex: group.getAttribute("tabindex"), + tabbable: radios.filter((r) => r.getAttribute("tabindex") === "0").length, + total: radios.length, + }; + }, { group: GROUP, radios: RADIOS }); + + expect(state.groupTabindex, "the radiogroup must not be in the tab order").toBeNull(); + expect(state.tabbable, "exactly one radio carries tabindex=0").toBe(1); + expect(state.total).toBeGreaterThan(2); + }); +}); + +// A tag route and the in-page filter produce the same view, so they must produce +// the same heading. They did not: /challenges/<tag>/ rendered "<Tag> Challenges" +// while filtering by pill left "Open Source Challenges", and the pre-migration +// app used the latter on both. +test.describe("heading is stable across how the filter was reached", () => { + test.use({ viewport: { width: 1400, height: 900 } }); + + const EXPECTED = "Open Source Challenges"; + + for (const tag of ["backstage", "kubernetes", "opentelemetry"]) { + test(`/challenges/${tag}/ keeps the page heading`, async ({ page }) => { + await gotoChallenges(page, "h1"); + const unfiltered = await page.locator("h1").innerText(); + expect(unfiltered).toBe(EXPECTED); + + await page.goto(`/challenges/${tag}/`); + await page.waitForSelector("h1"); + expect(await page.locator("h1").innerText()).toBe(EXPECTED); + + // The tag is still surfaced, just not as the page heading. + const count = new RegExp(`\\d+ challenges? · ${tag}`, "i"); + await expect(page.locator("p").filter({ hasText: count })).toBeVisible(); + + // Not in the live region though: arriving at a filtered URL is not an + // interaction, and a live region must not announce the state a page + // loaded in. It only speaks once the user changes a filter, which + // challenges-filter-deep.spec.ts covers. + await expect(page.locator("[data-live-count]")).toBeEmpty(); + }); + } + + test("filtering by pill and by URL agree", async ({ page }) => { + await gotoChallenges(page, "h1"); + await page.getByRole("button", { name: "Backstage", exact: true }).first().click(); + await page.waitForTimeout(150); + const viaPill = await page.locator("h1").innerText(); + + await page.goto("/challenges/backstage/"); + await page.waitForSelector("h1"); + const viaUrl = await page.locator("h1").innerText(); + + expect(viaPill).toBe(viaUrl); + expect(viaUrl).toBe(EXPECTED); + }); +}); + +// Regression: ChallengesFilter registered two document-level listeners +// (mousedown + keydown) inside initChallengesFilter(), which ran on every +// astro:page-load, but never removed them. Each trip to /challenges/ added +// another pair on top of the survivors from previous visits. The fix attaches +// a teardown on astro:before-swap that removes exactly the handlers added in +// the current page lifecycle. +test.describe("document listener lifecycle", () => { + test("no listener accumulation across repeated navigations", async ({ page }) => { + // Instrument EventTarget.prototype before any page script runs so we can + // measure the net number of active mousedown listeners on document. + await page.addInitScript(() => { + let added = 0; + let removed = 0; + const origAdd = EventTarget.prototype.addEventListener; + const origRemove = EventTarget.prototype.removeEventListener; + (EventTarget.prototype as any).addEventListener = function ( + type: string, + listener: EventListenerOrEventListenerObject | null, + opts?: boolean | AddEventListenerOptions, + ) { + if (type === "mousedown" && (this as Node) === document) added++; + return origAdd.call(this, type, listener, opts); + }; + (EventTarget.prototype as any).removeEventListener = function ( + type: string, + listener: EventListenerOrEventListenerObject | null, + opts?: boolean | EventListenerOptions, + ) { + if (type === "mousedown" && (this as Node) === document) removed++; + return origRemove.call(this, type, listener, opts); + }; + (window as any).__listenerStats = () => ({ added, removed, net: added - removed }); + }); + + // Seed consent to dismiss the banner (not relevant to this test). + await page.addInitScript(() => + localStorage.setItem( + "analytics_consent", + JSON.stringify({ value: "denied", timestamp: Date.now() }), + ), + ); + + // Start on /about/ — a page that does NOT include ChallengesFilter. + // There may be noise from Playwright's test harness, so we record a + // baseline net count before any ChallengesFilter navigations and assert + // relative to it, not against an absolute zero. + await page.goto("/about/"); + await page.waitForLoadState("load"); + + const baselineNet = await page.evaluate( + () => (window as any).__listenerStats().net as number, + ); + + const challengesLink = () => + page.getByRole("link", { name: "Challenges", exact: true }).first(); + const aboutLink = () => + page.getByRole("link", { name: "About", exact: true }).first(); + + // Three round-trips: about → challenges → about → challenges → … + for (let i = 0; i < 3; i++) { + await challengesLink().click(); + await page.waitForURL("**/challenges/"); + await page.waitForLoadState("networkidle"); + + if (i < 2) { + await aboutLink().click(); + await page.waitForURL("**/about/"); + await page.waitForLoadState("networkidle"); + } + } + + // Currently on /challenges/ (3rd arrival). + const { added, removed, net } = await page.evaluate( + () => (window as any).__listenerStats() as { added: number; removed: number; net: number }, + ); + + // Each departure from /challenges/ must have removed the listener added on + // arrival; teardown ran on the two returns to /about/. + expect(removed, "teardown must fire on each astro:before-swap").toBeGreaterThanOrEqual(2); + // Each of the three arrivals at /challenges/ adds exactly one listener. + expect(added - baselineNet, "each navigation to /challenges/ adds one listener").toBe(3); + // Exactly one ChallengesFilter listener should be live right now. net above + // baseline by exactly 1 means: the current listener was added and the two + // from earlier trips were torn down. More than +1 means teardown didn't run. + expect(net - baselineNet, "only one listener active at a time").toBe(1); + }); +}); diff --git a/e2e/consent-ui.spec.ts b/e2e/consent-ui.spec.ts new file mode 100644 index 000000000..749da3baa --- /dev/null +++ b/e2e/consent-ui.spec.ts @@ -0,0 +1,263 @@ +// SPDX-FileCopyrightText: 2025 OffOn contributors +// SPDX-License-Identifier: MIT + +// Consent banner UI contract: focus handling, reflow safety, and action framing. +// +// The state machine itself is covered by consent.spec.ts and by the unit tests +// on src/stores/consent.ts. This file covers the parts that live in the markup +// and are easy to regress silently when the component is reimplemented. + +import { test, expect, type Page } from "@playwright/test"; + +const STORAGE_KEY = "analytics_consent"; +const GTAG_HOST = "**/googletagmanager.com/**"; + +const banner = (page: Page) => page.getByRole("region", { name: "This site uses analytics cookies" }); +const accept = (page: Page) => page.getByRole("button", { name: "Accept analytics cookies" }); +const decline = (page: Page) => page.getByRole("button", { name: "Decline analytics cookies" }); +const cookieButton = (page: Page) => page.getByRole("button", { name: "Change cookie preferences" }); + +async function stubGtag(page: Page): Promise<void> { + await page.route(GTAG_HOST, (route) => + route.fulfill({ status: 200, contentType: "application/javascript", body: "" }), + ); +} + +async function seedConsent(page: Page, value: "granted" | "denied"): Promise<void> { + await page.addInitScript( + ([key, v]) => localStorage.setItem(key, JSON.stringify({ value: v, timestamp: Date.now() })), + [STORAGE_KEY, value] as const, + ); +} + +test.beforeEach(async ({ page }) => { + await stubGtag(page); +}); + +// Restoring a stored choice on load is a state change but not a user action. +// Focusing there strands keyboard users past the skip-nav link on every page +// load, which is exactly what the previous implementation did. +test.describe("focus is not stolen on load", () => { + for (const stored of ["granted", "denied"] as const) { + test(`stored "${stored}" restores without moving focus`, async ({ page }) => { + await seedConsent(page, stored); + await page.goto("/"); + await page.waitForLoadState("load"); + + await expect(cookieButton(page)).toBeVisible(); + await expect(banner(page)).toBeHidden(); + + const active = await page.evaluate(() => document.activeElement?.tagName ?? "NONE"); + expect(active, "focus must stay at the top of the document").toBe("BODY"); + + // The skip link is still the first thing a keyboard user reaches. + await page.keyboard.press("Tab"); + await expect(page.locator(":focus")).toContainText("Skip to main content"); + }); + } + + test("undecided shows the banner without grabbing focus", async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("load"); + await expect(banner(page)).toBeVisible(); + expect(await page.evaluate(() => document.activeElement?.tagName ?? "NONE")).toBe("BODY"); + }); +}); + +test.describe("focus follows a genuine choice", () => { + test("Accept moves focus to the preferences button", async ({ page }) => { + await page.goto("/"); + await accept(page).click(); + await expect(cookieButton(page)).toBeFocused(); + }); + + test("Decline moves focus to the preferences button", async ({ page }) => { + await page.goto("/"); + await decline(page).click(); + await expect(cookieButton(page)).toBeFocused(); + }); + + test("reopening preferences moves focus to Decline", async ({ page }) => { + await seedConsent(page, "denied"); + await page.goto("/"); + await cookieButton(page).click(); + await expect(decline(page)).toBeFocused(); + }); +}); + +test.describe("declining is as easy as accepting", () => { + test("Decline comes first in DOM and tab order", async ({ page }) => { + await page.goto("/"); + const labels = await banner(page) + .locator("button") + .evaluateAll((els) => els.map((e) => e.getAttribute("aria-label"))); + expect(labels).toEqual(["Decline analytics cookies", "Accept analytics cookies"]); + }); + + test("both actions are solid and the same size", async ({ page }) => { + await page.goto("/"); + const box = async (l: ReturnType<typeof accept>) => (await l.boundingBox())!; + const [d, a] = [await box(decline(page)), await box(accept(page))]; + expect(Math.abs(d.height - a.height)).toBeLessThanOrEqual(1); + + const declineBg = await decline(page).evaluate((el) => getComputedStyle(el).backgroundColor); + expect(declineBg, "Decline must be filled, not an outline button").not.toMatch( + /rgba\(0, 0, 0, 0\)|transparent/, + ); + }); +}); + +test.describe("reflow safety", () => { + test("both actions stay reachable at 400% zoom", async ({ page }) => { + // 400% of a 1280x1024 reference viewport. + await page.setViewportSize({ width: 320, height: 256 }); + await page.goto("/"); + await expect(banner(page)).toBeVisible(); + + const box = (await banner(page).boundingBox())!; + expect(box.height, "banner must not exceed 80vh").toBeLessThanOrEqual(256 * 0.8 + 2); + + for (const control of [decline(page), accept(page)]) { + await control.scrollIntoViewIfNeeded(); + await expect(control).toBeInViewport(); + await control.focus(); + await expect(control).toBeFocused(); + } + }); + + test("the banner scrolls rather than clipping its actions", async ({ page }) => { + await page.setViewportSize({ width: 320, height: 256 }); + await page.goto("/"); + const overflow = await banner(page) + .locator("> div") + .evaluate((el) => getComputedStyle(el).overflowY); + expect(overflow).toBe("auto"); + }); +}); + +// The analytics lifecycle was moved out of the banner into its own script in +// Layout.astro. The risk that motivated it: if the listener is registered after +// astro:page-load has already fired, the first page_view of the session is lost. +test.describe("page_view fires", () => { + const pageViews = (page: Page): Promise<number> => + page.evaluate( + () => + ((window as unknown as { dataLayer?: unknown[] }).dataLayer ?? []).filter( + (entry) => (entry as unknown[])[0] === "event" && (entry as unknown[])[1] === "page_view", + ).length, + ); + + test("a returning granted visitor gets a page_view on the first load", async ({ page }) => { + await seedConsent(page, "granted"); + await page.goto("/"); + await page.waitForLoadState("load"); + await expect.poll(() => pageViews(page), { timeout: 5000 }).toBeGreaterThanOrEqual(1); + }); + + test("and another on each client-side navigation", async ({ page }) => { + await seedConsent(page, "granted"); + await page.goto("/"); + await expect.poll(() => pageViews(page)).toBeGreaterThanOrEqual(1); + const first = await pageViews(page); + + await page.getByRole("link", { name: "Challenges", exact: true }).first().click(); + await page.waitForURL("**/challenges/"); + await expect.poll(() => pageViews(page)).toBeGreaterThan(first); + }); + + test("an undecided visitor produces none", async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("load"); + expect(await pageViews(page)).toBe(0); + }); + + test("a declined visitor produces none", async ({ page }) => { + await seedConsent(page, "denied"); + await page.goto("/"); + await page.waitForLoadState("load"); + expect(await pageViews(page)).toBe(0); + }); +}); + +// Regression: ConsentBanner used module-scope addEventListener calls that +// targeted DOM nodes replaced by Astro's ClientRouter on every client-side +// navigation. After a navigation the buttons were present in the DOM but +// their click handlers were attached to the dead pre-swap nodes. +test.describe("post-navigation button functionality", () => { + // Navigate client-side to /challenges/ then exercise the banner there. + // The banner is in Layout.astro so it is present on every page; the test + // deliberately does NOT reload between navigations. + async function goToChallengesViaClientRouter(page: Page): Promise<void> { + await page.getByRole("link", { name: "Challenges", exact: true }).first().click(); + await page.waitForURL("**/challenges/"); + await page.waitForLoadState("networkidle"); + } + + test("Accept works after a client-side navigation", async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("load"); + + await goToChallengesViaClientRouter(page); + + await accept(page).click(); + + const stored = await page.evaluate( + (key) => JSON.parse(localStorage.getItem(key) ?? "null")?.value, + STORAGE_KEY, + ); + expect(stored).toBe("granted"); + await expect(cookieButton(page)).toBeVisible(); + await expect(banner(page)).toBeHidden(); + }); + + test("Decline works after a client-side navigation", async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("load"); + + await goToChallengesViaClientRouter(page); + + await decline(page).click(); + + const stored = await page.evaluate( + (key) => JSON.parse(localStorage.getItem(key) ?? "null")?.value, + STORAGE_KEY, + ); + expect(stored).toBe("denied"); + await expect(cookieButton(page)).toBeVisible(); + await expect(banner(page)).toBeHidden(); + }); + + // Regression: the Reset button starts `hidden` in the server HTML and was + // only revealed by the $consent subscription. After a client-side navigation + // the old subscription (on dead DOM nodes) never fired; the new DOM's Reset + // button stayed hidden indefinitely for returning visitors. + test("Reset button visible for returning users after navigation", async ({ page }) => { + await seedConsent(page, "granted"); + await page.goto("/"); + await page.waitForLoadState("load"); + + // Baseline: button should be visible on first load. + await expect(cookieButton(page)).toBeVisible(); + + // Navigate client-side. + await goToChallengesViaClientRouter(page); + + // The $consent subscription must re-run and reveal the button on the + // new DOM; it must not be stuck at the server-rendered `hidden` default. + await expect(cookieButton(page)).toBeVisible(); + }); + + test("Reset triggers banner reappearance after navigation", async ({ page }) => { + await seedConsent(page, "denied"); + await page.goto("/"); + await page.waitForLoadState("load"); + + await goToChallengesViaClientRouter(page); + + await cookieButton(page).click(); + + // After reset, consent is null → banner should appear, cookie button gone. + await expect(banner(page)).toBeVisible(); + await expect(cookieButton(page)).toBeHidden(); + }); +}); diff --git a/e2e/consent.spec.ts b/e2e/consent.spec.ts new file mode 100644 index 000000000..1dcf71b33 --- /dev/null +++ b/e2e/consent.spec.ts @@ -0,0 +1,183 @@ +// Runtime regression tests for the GA4 gated-load consent state machine +// (src/stores/consent.ts + ConsentBanner.astro). Asserts the observable effects +// — banner state, localStorage, and whether the gtag.js script tag is injected — +// without loading real Google Analytics: googletagmanager.com is routed to an +// empty stub so the injected <script> "loads" but hits no external network. + +import { test, expect, type Page } from "@playwright/test"; + +const GTAG_HOST = "**/googletagmanager.com/**"; +const STORAGE_KEY = "analytics_consent"; + +async function stubGtag(page: Page): Promise<void> { + await page.route(GTAG_HOST, (route) => + route.fulfill({ status: 200, contentType: "application/javascript", body: "" }), + ); +} + +const gtagScript = (page: Page) => page.locator('script[src*="googletagmanager.com/gtag/js"]'); +const accept = (page: Page) => page.getByRole("button", { name: "Accept Analytics" }); +const decline = (page: Page) => page.getByRole("button", { name: "Decline" }); +const cookieButton = (page: Page) => page.getByRole("button", { name: "Change cookie preferences" }); + +async function storedConsent(page: Page): Promise<string | null> { + return page.evaluate((key) => { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw).value as string) : null; + }, STORAGE_KEY); +} + +test.describe("consent: gated load", () => { + test.beforeEach(async ({ page }) => { + await stubGtag(page); + }); + + test("no gtag.js and banner shown before a decision", async ({ page }) => { + let hitGoogle = false; + page.on("request", (r) => { + if (r.url().includes("googletagmanager.com")) hitGoogle = true; + }); + await page.goto("/"); + await page.waitForLoadState("load"); + await expect(accept(page)).toBeVisible(); + await expect(decline(page)).toBeVisible(); + await expect(gtagScript(page)).toHaveCount(0); + expect(hitGoogle, "no request to Google before consent").toBe(false); + expect(await storedConsent(page)).toBeNull(); + }); + + test("Accept injects gtag.js, stores granted, swaps to the cookie button", async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("load"); + await accept(page).click(); + await expect(gtagScript(page)).toHaveCount(1); + expect(await storedConsent(page)).toBe("granted"); + await expect(accept(page)).toHaveCount(0); + await expect(cookieButton(page)).toBeVisible(); + }); + + test("Decline stores denied and does NOT inject gtag.js", async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("load"); + await decline(page).click(); + await expect(gtagScript(page)).toHaveCount(0); + expect(await storedConsent(page)).toBe("denied"); + await expect(cookieButton(page)).toBeVisible(); + }); + + test("Cookie preferences resets to undecided and reopens the banner", async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("load"); + await decline(page).click(); + await cookieButton(page).click(); + await expect(accept(page)).toBeVisible(); + expect(await storedConsent(page)).toBeNull(); + }); + + test("stored granted re-injects gtag.js on load without prompting", async ({ page }) => { + await page.addInitScript((key) => { + localStorage.setItem(key, JSON.stringify({ value: "granted", timestamp: Date.now() })); + }, STORAGE_KEY); + await page.goto("/"); + await page.waitForLoadState("load"); + await expect(gtagScript(page)).toHaveCount(1); + await expect(accept(page)).toHaveCount(0); + await expect(cookieButton(page)).toBeVisible(); + }); + + test("clicks are tracked only after consent is granted", async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("load"); + const themeToggle = page.getByRole("button", { name: /switch to (light|dark) mode/i }); + const clickEvents = () => + page.evaluate(() => (window.dataLayer ?? []).filter((a) => (a as unknown[])[1] === "click_event").length); + + // Before consent: clicking a (non-navigating) button records nothing. + await themeToggle.click(); + expect(await clickEvents()).toBe(0); + + await page.getByRole("button", { name: "Accept Analytics" }).click(); + await themeToggle.click(); + expect(await clickEvents()).toBeGreaterThan(0); + }); + + test("stored denied stays silent (no gtag.js, no banner)", async ({ page }) => { + await page.addInitScript((key) => { + localStorage.setItem(key, JSON.stringify({ value: "denied", timestamp: Date.now() })); + }, STORAGE_KEY); + await page.goto("/"); + await page.waitForLoadState("load"); + await expect(gtagScript(page)).toHaveCount(0); + await expect(accept(page)).toHaveCount(0); + await expect(cookieButton(page)).toBeVisible(); + }); + + test("granted → reset → denied clears stored value", async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("load"); + await accept(page).click(); + expect(await storedConsent(page)).toBe("granted"); + await cookieButton(page).click(); + await expect(accept(page)).toBeVisible(); + expect(await storedConsent(page)).toBeNull(); + await decline(page).click(); + expect(await storedConsent(page)).toBe("denied"); + }); + + test("denied → reset → granted injects gtag.js", async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("load"); + await decline(page).click(); + expect(await storedConsent(page)).toBe("denied"); + await cookieButton(page).click(); + await expect(accept(page)).toBeVisible(); + await accept(page).click(); + await expect(gtagScript(page)).toHaveCount(1); + expect(await storedConsent(page)).toBe("granted"); + }); + + test("GPC active forces denied without prompting", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "globalPrivacyControl", { value: true, configurable: true }); + }); + await page.goto("/"); + await page.waitForLoadState("load"); + await expect(gtagScript(page)).toHaveCount(0); + await expect(accept(page)).toHaveCount(0); + await expect(cookieButton(page)).toBeVisible(); + expect(await storedConsent(page)).toBe("denied"); + }); + + test("granted → denied: Decline after a previously granted session revokes consent", async ({ page }) => { + // Start with stored granted — gtag.js should inject, cookie button shown, no banner. + await page.addInitScript((key) => { + localStorage.setItem(key, JSON.stringify({ value: "granted", timestamp: Date.now() })); + }, STORAGE_KEY); + await page.goto("/"); + await page.waitForLoadState("load"); + await expect(gtagScript(page)).toHaveCount(1); + await expect(accept(page)).toHaveCount(0); + // Open preferences, then decline. + await cookieButton(page).click(); + await expect(accept(page)).toBeVisible(); + await decline(page).click(); + expect(await storedConsent(page)).toBe("denied"); + // Script stays injected (not removed), but cookie button is shown again. + await expect(gtagScript(page)).toHaveCount(1); + await expect(cookieButton(page)).toBeVisible(); + }); + + test("GPC active + stored granted still injects gtag.js (explicit prior consent wins)", async ({ page }) => { + await page.addInitScript((key) => { + Object.defineProperty(navigator, "globalPrivacyControl", { value: true, configurable: true }); + localStorage.setItem(key, JSON.stringify({ value: "granted", timestamp: Date.now() })); + }, STORAGE_KEY); + await page.goto("/"); + await page.waitForLoadState("load"); + // Explicit prior grant overrides GPC: inject gtag.js, show cookie button. + await expect(gtagScript(page)).toHaveCount(1); + await expect(accept(page)).toHaveCount(0); + await expect(cookieButton(page)).toBeVisible(); + expect(await storedConsent(page)).toBe("granted"); + }); +}); diff --git a/e2e/hero-cta.spec.ts b/e2e/hero-cta.spec.ts new file mode 100644 index 000000000..76f4d7172 --- /dev/null +++ b/e2e/hero-cta.spec.ts @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2025 OffOn contributors +// SPDX-License-Identifier: MIT + +// The hero's primary CTA is an in-page jump to the adventure grid, which is why +// its icon is a down arrow. It had been changed to navigate to /challenges/ +// while keeping the arrow, so the icon contradicted the behaviour. + +import { test, expect } from "@playwright/test"; + +test("Start a Challenge scrolls to the adventure grid without navigating", async ({ page }) => { + await page.goto("/"); + const cta = page.getByRole("link", { name: /Start a Challenge/ }); + await expect(cta).toHaveAttribute("href", "#challenges"); + + expect(await page.evaluate(() => window.scrollY)).toBe(0); + await cta.click(); + + await expect(page).toHaveURL(/#challenges$/); + await page.waitForFunction(() => window.scrollY > 0); + + // The target sits below the fixed navbar, not under it. + const top = await page.locator("#challenges").evaluate((el) => el.getBoundingClientRect().top); + expect(top).toBeGreaterThanOrEqual(0); + expect(top).toBeLessThan(200); +}); + +test("the target section exists on the only page that renders the hero", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("#challenges")).toHaveCount(1); +}); diff --git a/e2e/hydration.spec.ts b/e2e/hydration.spec.ts deleted file mode 100644 index 851be9e56..000000000 --- a/e2e/hydration.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -// Hydration regression tests. -// Verifies that prerendered pages hydrate without React warnings. -// Requires a production build. Run `npm run build` before `npm run test:e2e`. - -import { test, expect } from "@playwright/test"; - -// Representative subset of the prerender list covering the main layout types. -// Does not duplicate the full route audit in smoke.spec.ts; focuses on routes -// that exercise state initialisation patterns that can mismatch on hydration. -const ROUTES = [ - "/", - "/adventures", - "/challenges", - "/about", - "/handbook", - "/privacy", - "/accessibility", - "/contribute", - "/sponsors", - "/404", - "/adventures/blind-by-design", - "/adventures/blind-by-design/levels/beginner", - "/challenges/kyverno", -]; - -// React production runtime emits "Minified React error #N" to console.error -// for hydration mismatches. Patterns cover both prod error codes and the -// dev-mode readable text (future dev-build opt-in). -const HYDRATION_PATTERNS = [ - /Minified React error #418/, - /Minified React error #423/, - /Minified React error #425/, - /Hydration failed/i, - /There was an error while hydrating/i, - /Expected server HTML/i, -]; - -function isHydrationWarning(text: string): boolean { - return HYDRATION_PATTERNS.some((p) => p.test(text)); -} - -test.describe("hydration: prerendered routes", () => { - for (const route of ROUTES) { - test(`${route} hydrates without React warnings`, async ({ page }) => { - const hydrationErrors: string[] = []; - - page.on("console", (msg) => { - if (msg.type() === "error" && isHydrationWarning(msg.text())) { - hydrationErrors.push(msg.text()); - } - }); - - await page.goto(route); - await page.waitForLoadState("networkidle"); - - expect( - hydrationErrors, - `React hydration warnings on ${route}:\n${hydrationErrors.join("\n")}`, - ).toHaveLength(0); - }); - } -}); - -// Exercise /challenges/?topics=kyverno: the prerendered HTML has hasFiltered=false -// (no params at build time). The client reads the param in useEffect, not in the -// useState initializer, so the first render matches and React should not warn. -test("hydration: /challenges/?topics=kyverno hydrates with search params", async ({ page }) => { - const hydrationErrors: string[] = []; - - page.on("console", (msg) => { - if (msg.type() === "error" && isHydrationWarning(msg.text())) { - hydrationErrors.push(msg.text()); - } - }); - - await page.goto("/challenges/?topics=kyverno"); - await page.waitForLoadState("networkidle"); - - expect( - hydrationErrors, - `React hydration warnings on /challenges/?topics=kyverno:\n${hydrationErrors.join("\n")}`, - ).toHaveLength(0); -}); - -// Exercise the light-theme path: the inline themeScript in root.tsx applies the -// "light" class to <html> before React mounts. The prerendered HTML has -// className="dark". React sees a mismatch but suppresses it via -// suppressHydrationWarning on <html>. This test confirms suppression is -// working and no other component leaks an unsuppressed mismatch. -test("hydration: /challenges hydrates with stored light theme in localStorage", async ({ - page, - context, -}) => { - await context.addInitScript(() => { - localStorage.setItem("theme", "light"); - }); - - const hydrationErrors: string[] = []; - - page.on("console", (msg) => { - if (msg.type() === "error" && isHydrationWarning(msg.text())) { - hydrationErrors.push(msg.text()); - } - }); - - await page.goto("/challenges"); - await page.waitForLoadState("networkidle"); - - expect( - hydrationErrors, - `React hydration warnings with light theme:\n${hydrationErrors.join("\n")}`, - ).toHaveLength(0); -}); diff --git a/e2e/inline-spacing.spec.ts b/e2e/inline-spacing.spec.ts new file mode 100644 index 000000000..850cf3dd2 --- /dev/null +++ b/e2e/inline-spacing.spec.ts @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2025 OffOn contributors +// SPDX-License-Identifier: MIT + +// Inline links must not be jammed against the words around them. +// +// Astro strips the whitespace between a text node and an adjacent element when +// the source has a newline there, so markup that reads correctly renders as +// "See ourPrivacy Policyfor details." JSX had the same behaviour and the React +// source carried explicit `{" "}`; Vue's compiler condensed it to a single space +// instead, so the requirement disappeared from view and came back with the port +// to .astro. +// +// This scans the build rather than a live page: it is a text-rendering defect +// that no accessibility or smoke assertion looks at, and it is close to +// invisible when reviewing the source. + +import { test, expect } from "@playwright/test"; +import { readdirSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const DIST = resolve(import.meta.dirname, "..", "dist"); + +function htmlFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = resolve(dir, entry.name); + if (entry.isDirectory()) return htmlFiles(full); + return entry.name.endsWith(".html") ? [full] : []; + }); +} + +/** Strip regions where adjacency is meaningless or intentional. */ +function stripCodeRegions(html: string): string { + return html.replace(/<(script|style|pre|code)\b[\s\S]*?<\/\1>/g, ""); +} + +// Characters that may legitimately sit against a link with no space: +// an opening bracket before it, and closing punctuation after it. +const OK_BEFORE = /[\s>(["'‘’“”\-–—/]/; +const OK_AFTER = /[\s.,;:!?)\]<&"'‘’“”\-–—/]/; + +test("no inline link is jammed against surrounding text", () => { + const offenders: string[] = []; + + for (const file of htmlFiles(DIST)) { + const html = stripCodeRegions(readFileSync(file, "utf8")); + const rel = file.slice(DIST.length + 1); + + for (const match of html.matchAll(/(.)<a\s[^>]*>([^<]{0,24})/g)) { + if (!OK_BEFORE.test(match[1])) { + offenders.push(`${rel}: "...${match[1]}" runs into link "${match[2].trim()}"`); + } + } + for (const match of html.matchAll(/>([^<]{0,24})<\/a>(.)/g)) { + if (!OK_AFTER.test(match[2])) { + offenders.push(`${rel}: link "${match[1].trim()}" runs into "${match[2]}..."`); + } + } + } + + // Report one line per distinct message so a failure names the component. + expect( + [...new Set(offenders.map((o) => o.replace(/^[^:]+: /, "")))].sort(), + `${offenders.length} occurrences. Add {" "} around the link in the .astro source.`, + ).toEqual([]); +}); diff --git a/e2e/mobile-menu.spec.ts b/e2e/mobile-menu.spec.ts new file mode 100644 index 000000000..d7502123a --- /dev/null +++ b/e2e/mobile-menu.spec.ts @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: 2025 OffOn contributors +// SPDX-License-Identifier: MIT + +// Mobile navigation drawer: keyboard trap, focus handling and background inerting. +// +// Written against observable behaviour and the public DOM contract only, never +// against the component's internals, so it holds for any implementation. The +// contract these rely on: +// +// - a trigger matching `button[aria-controls="mobile-menu"]`, whose +// `aria-expanded` reflects the open state +// - a drawer with `id="mobile-menu"`, carrying `hidden` while closed +// - while open, every body child except the nav's own subtree is `inert` +// and `aria-hidden` +// +// A drawer with no trap strands keyboard and screen-reader users behind an +// overlay they cannot leave, so these are non-negotiable. + +import { test, expect, type Page } from "@playwright/test"; + +// The drawer is the sub-md UI; above 768px the trigger is display:none. +test.use({ viewport: { width: 390, height: 780 } }); + +const TRIGGER = 'button[aria-controls="mobile-menu"]'; +const DRAWER = "#mobile-menu"; + +test.beforeEach(async ({ page }) => { + // Skip the consent banner: it is a body sibling and would otherwise be one + // more thing to reason about when asserting inert state. + await page.addInitScript(() => + localStorage.setItem( + "analytics_consent", + JSON.stringify({ value: "denied", timestamp: Date.now() }), + ), + ); + await page.goto("/"); + await page.waitForSelector(TRIGGER); +}); + +/** Tag names + accessible names of what is focusable inside the drawer, in order. */ +async function drawerFocusables(page: Page): Promise<string[]> { + return page.evaluate(() => { + const drawer = document.querySelector("#mobile-menu"); + if (!drawer) return []; + return Array.from( + drawer.querySelectorAll<HTMLElement>( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [contenteditable]:not([contenteditable="false"]), [tabindex]:not([tabindex="-1"])', + ), + ).map((el) => `${el.tagName.toLowerCase()}:${(el.textContent ?? "").trim()}`); + }); +} + +async function activeDescriptor(page: Page): Promise<string> { + return page.evaluate(() => { + const el = document.activeElement as HTMLElement | null; + if (!el) return "none"; + return `${el.tagName.toLowerCase()}:${(el.textContent ?? "").trim()}`; + }); +} + +async function openDrawer(page: Page): Promise<void> { + await page.click(TRIGGER); + await expect(page.locator(DRAWER)).toBeVisible(); +} + +test("starts closed, with the drawer hidden and aria-expanded false", async ({ page }) => { + await expect(page.locator(TRIGGER)).toHaveAttribute("aria-expanded", "false"); + await expect(page.locator(DRAWER)).toBeHidden(); +}); + +test("opening moves focus to the first focusable in the drawer", async ({ page }) => { + await openDrawer(page); + await expect(page.locator(TRIGGER)).toHaveAttribute("aria-expanded", "true"); + + const items = await drawerFocusables(page); + expect(items.length).toBeGreaterThan(1); + expect(await activeDescriptor(page)).toBe(items[0]); +}); + +test("Tab from the last item wraps to the first", async ({ page }) => { + await openDrawer(page); + const items = await drawerFocusables(page); + + // Walk to the last item. + for (let i = 1; i < items.length; i++) await page.keyboard.press("Tab"); + expect(await activeDescriptor(page)).toBe(items[items.length - 1]); + + await page.keyboard.press("Tab"); + expect(await activeDescriptor(page)).toBe(items[0]); +}); + +test("Shift+Tab from the first item wraps to the last", async ({ page }) => { + await openDrawer(page); + const items = await drawerFocusables(page); + expect(await activeDescriptor(page)).toBe(items[0]); + + await page.keyboard.press("Shift+Tab"); + expect(await activeDescriptor(page)).toBe(items[items.length - 1]); +}); + +test("focus never escapes the drawer while it is open", async ({ page }) => { + await openDrawer(page); + const items = await drawerFocusables(page); + + for (let i = 0; i < items.length * 2 + 3; i++) { + await page.keyboard.press("Tab"); + const inside = await page.evaluate( + () => !!document.activeElement?.closest("#mobile-menu"), + ); + expect(inside, `focus left the drawer after ${i + 1} Tab presses`).toBe(true); + } +}); + +test("Escape closes the drawer and returns focus to the trigger", async ({ page }) => { + await openDrawer(page); + await page.keyboard.press("Escape"); + + await expect(page.locator(DRAWER)).toBeHidden(); + await expect(page.locator(TRIGGER)).toHaveAttribute("aria-expanded", "false"); + await expect(page.locator(TRIGGER)).toBeFocused(); +}); + +test("background body siblings are inert and aria-hidden while open, and restored on close", async ({ + page, +}) => { + const snapshot = (): Promise<{ total: number; inert: number; hidden: number }> => + page.evaluate(() => { + // The drawer's own top-level ancestor stays operable; everything else must not be. + let host: Element | null = document.querySelector("#mobile-menu"); + while (host && host.parentElement !== document.body) host = host.parentElement; + const siblings = Array.from(document.body.children).filter((el) => el !== host); + return { + total: siblings.length, + inert: siblings.filter((el) => el.hasAttribute("inert")).length, + hidden: siblings.filter((el) => el.getAttribute("aria-hidden") === "true").length, + }; + }); + + const before = await snapshot(); + expect(before.inert).toBe(0); + + await openDrawer(page); + const during = await snapshot(); + expect(during.total).toBeGreaterThan(0); + expect(during.inert, "every background body sibling must be inert").toBe(during.total); + expect(during.hidden, "every background body sibling must be aria-hidden").toBe(during.total); + + await page.keyboard.press("Escape"); + await expect(page.locator(DRAWER)).toBeHidden(); + const after = await snapshot(); + expect(after.inert).toBe(0); + expect(after.hidden).toBe(0); +}); + +test("the trigger toggles the drawer shut again", async ({ page }) => { + await openDrawer(page); + await page.click(TRIGGER); + await expect(page.locator(DRAWER)).toBeHidden(); + await expect(page.locator(TRIGGER)).toHaveAttribute("aria-expanded", "false"); +}); + +test("following a drawer link navigates and leaves no trap behind", async ({ page }) => { + await openDrawer(page); + await page.locator(`${DRAWER} a[href$="/challenges/"]`).first().click(); + + await page.waitForURL("**/challenges/"); + await expect(page.locator(DRAWER)).toBeHidden(); + + const stuck = await page.evaluate(() => + Array.from(document.body.children).filter((el) => el.hasAttribute("inert")).length, + ); + expect(stuck, "inert must be cleared after navigating away").toBe(0); +}); + +test("the trigger icon tracks the open state", async ({ page }) => { + const icons = (): Promise<string[]> => + page.evaluate((sel) => + Array.from(document.querySelector(sel)!.querySelectorAll("svg")) + .filter((s) => getComputedStyle(s).display !== "none") + .map((s) => + s.querySelector("path")?.getAttribute("d")?.includes("M4 5h16") ? "hamburger" : "close", + ), TRIGGER); + + expect(await icons()).toEqual(["hamburger"]); + await openDrawer(page); + expect(await icons()).toEqual(["close"]); + await page.keyboard.press("Escape"); + expect(await icons()).toEqual(["hamburger"]); +}); + +test("the trap includes form controls, not just links", async ({ page }) => { + // The drawer only holds links today. A trap whose selector silently skips an + // input is a trap with a hole in it, so prove the full selector is in use. + await openDrawer(page); + await page.evaluate(() => { + const input = document.createElement("input"); + input.type = "text"; + input.id = "probe"; + document.querySelector("#mobile-menu")!.appendChild(input); + }); + + const cycle: string[] = []; + for (let i = 0; i < 9; i++) { + await page.keyboard.press("Tab"); + cycle.push( + await page.evaluate(() => document.activeElement?.id || document.activeElement?.tagName || "?"), + ); + } + expect(cycle, `tab cycle was ${cycle.join(", ")}`).toContain("probe"); +}); + +test("crossing to the desktop breakpoint while open releases the trap", async ({ page }) => { + await openDrawer(page); + await page.setViewportSize({ width: 1400, height: 900 }); + + const stuck = await page.evaluate( + () => Array.from(document.body.children).filter((el) => el.hasAttribute("inert")).length, + ); + expect(stuck, "inert must clear when the breakpoint hides the drawer").toBe(0); + await expect(page.locator(TRIGGER)).toHaveAttribute("aria-expanded", "false"); +}); diff --git a/e2e/route-coverage.spec.ts b/e2e/route-coverage.spec.ts new file mode 100644 index 000000000..889f32690 --- /dev/null +++ b/e2e/route-coverage.spec.ts @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2025 OffOn contributors +// SPDX-License-Identifier: MIT + +// Drift gate for the hand-maintained route lists in a11y.spec.ts and +// smoke.spec.ts. +// +// Those lists used to be generated, so adding an adventure, level or tag +// automatically extended test coverage. They are hand-maintained now, which +// means a new route ships untested unless someone remembers. This walks the +// actual build and fails when a built route is in neither list nor the +// deliberate-exclusion set below, so the omission has to be a decision rather +// than an oversight. +// +// It also fails on the reverse: a listed route that no longer exists, which +// otherwise sits there passing vacuously. + +import { test, expect } from "@playwright/test"; +import { readdirSync, existsSync } from "node:fs"; +import { resolve, sep } from "node:path"; +import { A11Y_PAGES, SMOKE_ROUTES, ROUTES_WITHOUT_FULL_COVERAGE } from "./routes"; + +const DIST = resolve(import.meta.dirname, "..", "dist"); + +/** Every route the build emits, as "/path/" strings. */ +function builtRoutes(): string[] { + const out: string[] = []; + const walk = (dir: string, prefix: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + walk(resolve(dir, entry.name), `${prefix}${entry.name}/`); + } else if (entry.name === "index.html") { + out.push(prefix === "" ? "/" : `/${prefix}`); + } + } + }; + walk(DIST, ""); + return out.sort(); +} + +/** + * Routes that exist in dist/ but are not site pages, so they need no coverage: + * redirect stubs, and anything under the vendored deck/reveal trees. + */ +function isNonPageRoute(route: string): boolean { + return ( + route.startsWith("/deck/") || + route.startsWith("/deck-template/") || + route.startsWith("/reveal/") || + route.startsWith("/pr-preview/") || + // Static redirect stubs emitted by astro.config.mjs `redirects`. + route === "/docs/" || + route === "/docs/community-guide/" || + route === "/community-guide/" + ); +} + +test.describe("route coverage drift", () => { + test("the build exists (run npm run build first)", () => { + expect(existsSync(DIST), `no build at ${DIST}`).toBe(true); + expect(existsSync(resolve(DIST, "index.html"))).toBe(true); + }); + + test("every built page is covered by a11y.spec.ts, smoke.spec.ts, or an explicit exclusion", () => { + const covered = new Set([ + ...A11Y_PAGES, + ...Object.keys(SMOKE_ROUTES), + ...ROUTES_WITHOUT_FULL_COVERAGE, + ]); + + const uncovered = builtRoutes() + .filter((r) => !isNonPageRoute(r)) + .filter((r) => !covered.has(r)); + + expect( + uncovered, + "New routes are untested. Add each to A11Y_PAGES and SMOKE_ROUTES in " + + "e2e/routes.ts, or to ROUTES_WITHOUT_FULL_COVERAGE with a reason.", + ).toEqual([]); + }); + + test("no listed route has disappeared from the build", () => { + const built = new Set(builtRoutes()); + // /404/ is emitted as dist/404.html, not dist/404/index.html. + built.add("/404/"); + + const stale = [ + ...new Set([...A11Y_PAGES, ...Object.keys(SMOKE_ROUTES), ...ROUTES_WITHOUT_FULL_COVERAGE]), + ] + .filter((r) => !built.has(r)) + .sort(); + + expect(stale, "These routes are listed in e2e/routes.ts but the build no longer emits them.").toEqual( + [], + ); + }); + + test("every adventure, level and solution in the build has a11y coverage", () => { + // The content-derived routes are the ones that grow over time, so they get a + // stricter check than the static pages: axe must run on all of them. + const contentRoutes = builtRoutes().filter( + (r) => r.startsWith("/adventures/") && r !== "/adventures/", + ); + const missing = contentRoutes.filter( + (r) => !A11Y_PAGES.includes(r) && !ROUTES_WITHOUT_FULL_COVERAGE.includes(r), + ); + expect( + missing, + "Adventure/level/solution routes missing from A11Y_PAGES in e2e/routes.ts.", + ).toEqual([]); + }); + + test("path separator assumption holds on this platform", () => { + // builtRoutes() joins with "/" regardless of platform; guard the assumption. + expect(sep === "/" || sep === "\\").toBe(true); + }); +}); diff --git a/e2e/routes.ts b/e2e/routes.ts new file mode 100644 index 000000000..e8d2e4014 --- /dev/null +++ b/e2e/routes.ts @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2025 OffOn contributors +// SPDX-License-Identifier: MIT + +// Single source of truth for the routes the e2e suite covers, shared by +// a11y.spec.ts, smoke.spec.ts and the drift gate in route-coverage.spec.ts. +// Keeping one copy is what lets route-coverage.spec.ts prove the build and the +// tests agree; three hand-maintained copies could not. + +import { solution as beginnerSolution } from "@/data/solutions/echoes-lost-in-orbit/beginner"; +import { solution as intermediateSolution } from "@/data/solutions/echoes-lost-in-orbit/intermediate"; +import { solution as expertSolution } from "@/data/solutions/echoes-lost-in-orbit/expert"; + +// Route → expected exact <title>. Covers every layout type + all static pages. +export const SMOKE_ROUTES: Record<string, string> = { + "/": "OffOn - Vendor-Neutral. Open Source. Community-Driven", + "/adventures/": "Adventures - Open Source Learning Paths | OffOn", + "/adventures/blind-by-design/": "Blind by Design - OffOn Adventures", + "/adventures/blind-by-design/levels/beginner/": "Stand up the Lab - Blind by Design - OffOn", + "/adventures/building-cloudhaven/": "Building CloudHaven - OffOn Adventures", + "/adventures/building-cloudhaven/levels/beginner/": "The Foundation Stones - Building CloudHaven - OffOn", + "/adventures/dead-reckoning/": "Dead Reckoning - OffOn Adventures", + "/adventures/dead-reckoning/levels/expert/": "The Chronometer - Dead Reckoning - OffOn", + "/adventures/echoes-lost-in-orbit/": "Echoes Lost in Orbit - OffOn Adventures", + "/adventures/echoes-lost-in-orbit/levels/beginner/": "Broken Echoes - Echoes Lost in Orbit - OffOn", + "/adventures/echoes-lost-in-orbit/levels/beginner/solution/": + `${beginnerSolution.title} - Echoes Lost in Orbit - OffOn`, + "/adventures/echoes-lost-in-orbit/levels/intermediate/solution/": + `${intermediateSolution.title} - Echoes Lost in Orbit - OffOn`, + "/adventures/echoes-lost-in-orbit/levels/expert/solution/": + `${expertSolution.title} - Echoes Lost in Orbit - OffOn`, + "/adventures/lex-imperfecta/": "Lex Imperfecta - OffOn Adventures", + "/adventures/lex-imperfecta/levels/beginner/": "The Twelve Tables - Lex Imperfecta - OffOn", + "/adventures/the-ai-observatory/": "The AI Observatory - OffOn Adventures", + "/adventures/the-ai-observatory/levels/beginner/": "Calibrating the Lens - The AI Observatory - OffOn", + "/challenges/": "Open Source Challenges | OffOn", + "/challenges/opentelemetry/": "OpenTelemetry Challenges - OffOn", + "/about/": "About OffOn - Building the contributors and maintainers of tomorrow", + "/contribute/": "How to Contribute - OffOn", + "/handbook/": "Handbook - OffOn", + "/sponsors/": "Sponsorship and Independence - OffOn", + "/brand/": "Brand Guidelines - OffOn", + "/presentation-templates/": "Presentation Templates - OffOn", + "/privacy/": "Privacy Policy - OffOn", + "/accessibility/": "Accessibility Statement - OffOn", + "/404/": "Page Not Found - OffOn", +}; + +export const A11Y_PAGES: string[] = [ + "/", + "/adventures/", + "/challenges/", + "/adventures/blind-by-design/", + "/adventures/blind-by-design/levels/beginner/", + "/adventures/building-cloudhaven/", + "/adventures/building-cloudhaven/levels/beginner/", + "/adventures/dead-reckoning/", + "/adventures/dead-reckoning/levels/expert/", + "/adventures/echoes-lost-in-orbit/", + "/adventures/echoes-lost-in-orbit/levels/beginner/", + "/adventures/echoes-lost-in-orbit/levels/beginner/solution/", + "/adventures/echoes-lost-in-orbit/levels/intermediate/solution/", + "/adventures/echoes-lost-in-orbit/levels/expert/solution/", + "/adventures/lex-imperfecta/", + "/adventures/lex-imperfecta/levels/beginner/", + "/adventures/the-ai-observatory/", + "/adventures/the-ai-observatory/levels/beginner/", + "/challenges/opentelemetry/", + "/about/", + "/contribute/", + "/handbook/", + "/privacy/", + "/accessibility/", + "/sponsors/", + "/brand/", + "/presentation-templates/", + "/adventures/blind-by-design/levels/expert/", + "/adventures/blind-by-design/levels/intermediate/", + "/adventures/building-cloudhaven/levels/expert/", + "/adventures/building-cloudhaven/levels/intermediate/", + "/adventures/dead-reckoning/levels/beginner/", + "/adventures/dead-reckoning/levels/intermediate/", + "/adventures/echoes-lost-in-orbit/levels/expert/", + "/adventures/echoes-lost-in-orbit/levels/intermediate/", + "/adventures/lex-imperfecta/levels/expert/", + "/adventures/lex-imperfecta/levels/intermediate/", + "/adventures/the-ai-observatory/levels/expert/", + "/adventures/the-ai-observatory/levels/intermediate/", + "/404/", +]; + +/** + * Built routes deliberately left out of the per-route suites, each with a reason. + * Anything here is a decision; anything missing from every list fails the drift + * gate in route-coverage.spec.ts. + */ +export const ROUTES_WITHOUT_FULL_COVERAGE: string[] = [ + // 24 remaining /challenges/<tag>/ routes. They are the same page component with + // a different filter seed, so /challenges/opentelemetry/ is the representative. + // Listed explicitly rather than pattern-matched so a new tag still has to be + // acknowledged here. + "/challenges/argo-cd/", + "/challenges/argo-events/", + "/challenges/argo-rollouts/", + "/challenges/argo-workflows/", + "/challenges/backstage/", + "/challenges/flagd/", + "/challenges/gitea/", + "/challenges/github-actions/", + "/challenges/grafana/", + "/challenges/jaeger/", + "/challenges/java/", + "/challenges/kubernetes/", + "/challenges/kyverno/", + "/challenges/openfeature/", + "/challenges/openllmetry/", + "/challenges/opentofu/", + "/challenges/policy-reporter/", + "/challenges/prometheus/", + "/challenges/promql/", + "/challenges/python/", + "/challenges/spring-boot/", + "/challenges/tdd/", + "/challenges/terraform/", + "/challenges/trivy/", +]; diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 0585419b9..26f45b550 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,365 +1,92 @@ -// Requires a production build in dist/client/. Run `npm run build` before `npm run test:e2e`. +// Smoke + SEO checks for the Astro build. Verifies every prerendered route has +// a unique, correct <title>, a canonical URL matching the path, a meta +// description, exactly one <h1>, and that the theme-toggle island hydrates. +// Requires a production build in dist/ (webServer runs `astro preview`). -import { test, expect, type Locator } from "@playwright/test"; -import AxeBuilder from "@axe-core/playwright"; +import { test, expect } from "@playwright/test"; +import { SMOKE_ROUTES as ROUTES } from "./routes"; -type RouteSpec = { path: string; title: RegExp }; +const SITE_URL = "https://offon.dev"; -const ROUTES: RouteSpec[] = [ - { path: "/", title: /OffOn - Vendor-Neutral/ }, - { path: "/about", title: /Building the contributors/ }, - { path: "/contribute", title: /How to Contribute/ }, - { path: "/sponsors", title: /Sponsorship and Independence/ }, - { path: "/handbook", title: /Handbook/ }, - { path: "/privacy", title: /Privacy Policy/ }, - { path: "/accessibility", title: /Accessibility Statement/ }, - { path: "/brand", title: /Brand Guidelines/ }, - { path: "/presentation-templates", title: /Presentation Templates/ }, - { path: "/404", title: /Page Not Found/ }, - { path: "/adventures", title: /Adventures - Open Source Learning Paths/ }, - // GENERATED:adventures - { path: "/adventures/dead-reckoning", title: /Dead Reckoning/ }, - { path: "/adventures/dead-reckoning/levels/beginner", title: /Laying the Keel/ }, - { path: "/adventures/dead-reckoning/levels/intermediate", title: /Sea Trial/ }, - { path: "/adventures/dead-reckoning/levels/expert", title: /The Chronometer/ }, - { path: "/adventures/lex-imperfecta", title: /Lex Imperfecta/ }, - { path: "/adventures/lex-imperfecta/levels/beginner", title: /The Twelve Tables/ }, - { path: "/adventures/lex-imperfecta/levels/intermediate", title: /Governing the Provinces/ }, - { path: "/adventures/lex-imperfecta/levels/expert", title: /Quis Custodiet/ }, - { 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/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/building-cloudhaven", title: /Building CloudHaven/ }, - { 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/ }, - // /GENERATED:adventures - // GENERATED:solutions - { path: "/adventures/echoes-lost-in-orbit/levels/beginner/solution", title: /Solution/ }, - { path: "/adventures/echoes-lost-in-orbit/levels/expert/solution", title: /Solution/ }, - { path: "/adventures/echoes-lost-in-orbit/levels/intermediate/solution", title: /Solution/ }, - // /GENERATED:solutions - { path: "/challenges", title: /Open Source Challenges/ }, - // GENERATED:challenge-tags - { path: "/challenges/argo-cd", title: /Argo CD Challenges/ }, - { path: "/challenges/argo-events", title: /Argo Events Challenges/ }, - { path: "/challenges/argo-rollouts", title: /Argo Rollouts Challenges/ }, - { path: "/challenges/argo-workflows", title: /Argo Workflows Challenges/ }, - { path: "/challenges/backstage", title: /Backstage Challenges/ }, - { path: "/challenges/flagd", title: /flagd Challenges/ }, - { path: "/challenges/gitea", title: /Gitea Challenges/ }, - { path: "/challenges/github-actions", title: /GitHub Actions Challenges/ }, - { path: "/challenges/grafana", title: /Grafana Challenges/ }, - { path: "/challenges/jaeger", title: /Jaeger Challenges/ }, - { path: "/challenges/java", title: /Java Challenges/ }, - { path: "/challenges/kubernetes", title: /Kubernetes Challenges/ }, - { path: "/challenges/kyverno", title: /Kyverno Challenges/ }, - { path: "/challenges/openfeature", title: /OpenFeature Challenges/ }, - { path: "/challenges/openllmetry", title: /OpenLLMetry Challenges/ }, - { path: "/challenges/opentelemetry", title: /OpenTelemetry Challenges/ }, - { path: "/challenges/opentofu", title: /OpenTofu Challenges/ }, - { path: "/challenges/policy-reporter", title: /Policy Reporter 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/ }, - // /GENERATED:challenge-tags -]; - -// Contrast ratio helpers used by the WCAG 1.4.11 and hover-state test blocks -// below. Both accept a Playwright Locator so callers name the element they -// already have rather than passing a redundant CSS selector string. -// -// The sRGB linearisation math (lin / lum) is inline inside locator.evaluate() -// because evaluate() serialises the callback to run in browser context; it -// cannot close over module-level functions. The duplication between the two -// helpers is an unavoidable constraint of Playwright's evaluate API. - -async function getBorderContrast(target: Locator): Promise<number | null> { - return target.evaluate((el) => { - const parse = (s: string) => { - const m = s.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); - return m ? ([+m[1], +m[2], +m[3]] as [number, number, number]) : null; - }; - const border = parse(window.getComputedStyle(el).borderTopColor); - let bg: [number, number, number] | null = null; - let cur: Element | null = el; - while (cur) { - const bgStr = window.getComputedStyle(cur).backgroundColor; - const b = parse(bgStr); - if (b && bgStr !== "rgba(0, 0, 0, 0)") { bg = b; break; } - cur = cur.parentElement; - } - if (!border || !bg) return null; - const lin = (c: number) => { const n = c / 255; return n <= 0.04045 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4); }; - const lum = ([r, g, b]: [number, number, number]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); - const l1 = lum(border); - const l2 = lum(bg); - return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); - }); -} - -async function getTextContrast(target: Locator): Promise<number | null> { - return target.evaluate((el) => { - const parse = (s: string) => { - const m = s.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); - return m ? ([+m[1], +m[2], +m[3]] as [number, number, number]) : null; - }; - const fg = parse(window.getComputedStyle(el).color); - let bg: [number, number, number] | null = null; - let cur: Element | null = el; - while (cur) { - const bgStr = window.getComputedStyle(cur).backgroundColor; - const b = parse(bgStr); - if (b && bgStr !== "rgba(0, 0, 0, 0)") { bg = b; break; } - cur = cur.parentElement; - } - if (!fg || !bg) return null; - const lin = (c: number) => { const n = c / 255; return n <= 0.04045 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4); }; - const lum = ([r, g, b]: [number, number, number]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); - const l1 = lum(fg); - const l2 = lum(bg); - return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); - }); -} - -// Discourse-hosted avatars (community.offon.dev) are the only resource the -// prerendered pages fetch from a third party at runtime. Stub them with a 1x1 -// PNG so a transient 5xx from that service cannot fail the console-error -// assertion below; e2e must be hermetic and not depend on external uptime. -const STUB_PNG = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", - "base64", -); -test.beforeEach(async ({ page }) => { - await page.route(/https:\/\/community\.offon\.dev\//, (route) => - route.fulfill({ status: 200, contentType: "image/png", body: STUB_PNG }), - ); -}); - -test.describe("every prerendered route", () => { - for (const { path, title } of ROUTES) { +test.describe("SEO + smoke: every route", () => { + for (const [path, title] of Object.entries(ROUTES)) { test(path, async ({ page }) => { - const pageErrors: string[] = []; - const consoleErrors: string[] = []; - page.on("pageerror", (e) => pageErrors.push(e.message)); + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(`pageerror: ${e}`)); + // console.error too, not just uncaught exceptions: a caught-and-logged + // failure (a hydration warning, a rejected fetch) leaves the page standing + // but still means something is broken. page.on("console", (msg) => { - if (msg.type() === "error") consoleErrors.push(msg.text()); + if (msg.type() === "error") errors.push(`console.error: ${msg.text()}`); }); - - // Reduced motion must be set before navigation so the global - // prefers-reduced-motion CSS rule kills transitions from first paint. - // Calling it after goto leaves any in-flight transitions running and - // axe samples mid-animation colors that fail contrast. - await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto(path); + await page.waitForLoadState("load"); - // Wait for hydration and post-mount renders (consent banner, theme - // sync) to settle before asserting on error state. - await page.waitForLoadState("networkidle"); - - expect(pageErrors, `unexpected JS exceptions on ${path}:\n${pageErrors.join("\n")}`).toHaveLength(0); - expect(consoleErrors, `unexpected console.error on ${path}:\n${consoleErrors.join("\n")}`).toHaveLength(0); - await expect(page.locator("main#main-content")).toBeAttached(); await expect(page).toHaveTitle(title); - const a11y = await new AxeBuilder({ page }) - .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"]) - .analyze(); - expect(a11y.violations, `axe violations on ${path}`).toEqual([]); - }); - } -}); - -test.describe("every prerendered route (light mode)", () => { - for (const { path } of ROUTES) { - test(path, async ({ page }) => { - const pageErrors: string[] = []; - const consoleErrors: string[] = []; - page.on("pageerror", (e) => pageErrors.push(e.message)); - page.on("console", (msg) => { - if (msg.type() === "error") consoleErrors.push(msg.text()); + // Exactly one <h1>. + await expect(page.locator("h1")).toHaveCount(1); + + // Canonical present and correct (SITE_URL + path, trailing slash). + const canonical = await page.locator('link[rel="canonical"]').getAttribute("href"); + expect(canonical, `canonical on ${path}`).toBe(`${SITE_URL}${path}`); + + // Meta description present and non-empty. + const desc = await page.locator('meta[name="description"]').getAttribute("content"); + expect(desc?.length ?? 0, `meta description on ${path}`).toBeGreaterThan(0); + + // Open Graph essentials. + await expect(page.locator('meta[property="og:title"]')).toHaveAttribute("content", title); + await expect(page.locator('meta[property="og:url"]')).toHaveAttribute("content", `${SITE_URL}${path}`); + + // No duplicate id attributes. Abbreviation IDs are generated per content + // entry with no page context, so this is the only place the document-level + // uniqueness they promise can actually be checked. axe does not cover it: + // duplicate-id is deprecated and duplicate-id-aria only fires when the id + // is ARIA-referenced. + const dupes = await page.evaluate(() => { + const counts = new Map<string, number>(); + document.querySelectorAll("[id]").forEach((el) => { + const id = el.id; + if (id) counts.set(id, (counts.get(id) ?? 0) + 1); + }); + return [...counts].filter(([, n]) => n > 1).map(([id, n]) => `${id} x${n}`); }); + expect(dupes, `duplicate id attributes on ${path}`).toEqual([]); - await page.addInitScript(() => localStorage.setItem("theme", "light")); - // Set reduced motion before goto; see comment in dark-mode block above. - await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto(path); - - await expect(page.locator("html")).toHaveClass(/light/); - // Wait for hydration to fully settle; without this axe occasionally - // samples elements mid React-render with stale dark-mode computed - // colors from the initial dark-class server render. - await page.waitForLoadState("networkidle"); - - expect(pageErrors, `unexpected JS exceptions on ${path} (light mode):\n${pageErrors.join("\n")}`).toHaveLength(0); - expect(consoleErrors, `unexpected console.error on ${path} (light mode):\n${consoleErrors.join("\n")}`).toHaveLength(0); - - const a11y = await new AxeBuilder({ page }) - .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"]) - .analyze(); - expect(a11y.violations, `axe violations on ${path} (light mode)`).toEqual([]); + expect(errors, `console/page errors on ${path}`).toEqual([]); }); } }); -// axe-core does not check WCAG 1.4.11 border contrast on styled <a> link elements. -// This block fills that gap for the specific interactive chip/pill components. -test.describe("WCAG 1.4.11 border contrast: light mode (axe gap)", () => { - test("tag-chip-link border contrast >= 3:1 on challenge detail (light mode)", async ({ page }) => { - await page.addInitScript(() => localStorage.setItem("theme", "light")); - await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto("/adventures/blind-by-design/levels/beginner"); - await page.waitForLoadState("networkidle"); - const ratio = await getBorderContrast(page.locator(".tag-chip-link").first()); - expect(ratio, "tag-chip-link border contrast must be >= 3:1 (WCAG 1.4.11)").not.toBeNull(); - expect(ratio!).toBeGreaterThanOrEqual(3.0); - }); - - test("contributor-pill border contrast >= 3:1 on adventure page (light mode)", async ({ page }) => { - await page.addInitScript(() => localStorage.setItem("theme", "light")); - await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto("/adventures/blind-by-design"); - await page.waitForLoadState("networkidle"); - const ratio = await getBorderContrast(page.locator(".contributor-pill").first()); - expect(ratio, "contributor-pill border contrast must be >= 3:1 (WCAG 1.4.11)").not.toBeNull(); - expect(ratio!).toBeGreaterThanOrEqual(3.0); - }); -}); - -// axe-core and the static border tests above do not check hover states. -// This block catches hover color contrast violations in light mode. -// WCAG AAA thresholds: normal text (under 18px / non-bold under 14px) = 7:1, -// large text (18px+ or bold 14px+) = 4.5:1. -test.describe("hover state contrast: light mode", () => { - test("primary nav links (14px medium = normal text) hover >= 7:1", async ({ page }) => { - await page.addInitScript(() => localStorage.setItem("theme", "light")); - await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto("/"); - await page.waitForLoadState("networkidle"); - const link = page.getByRole("navigation", { name: "Main" }).getByRole("link", { name: "About" }); - await link.hover(); - const ratio = await getTextContrast(link); - expect(ratio, "nav link hover (14px medium = normal text) must be >= 7:1 (WCAG AAA)").not.toBeNull(); - expect(ratio!).toBeGreaterThanOrEqual(7.0); - }); - - test("inline prose links .docs-ext-link (16px normal = normal text) hover >= 7:1", async ({ page }) => { - await page.addInitScript(() => localStorage.setItem("theme", "light")); - await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto("/contribute"); - await page.waitForLoadState("networkidle"); - const link = page.locator(".docs-ext-link").first(); - const preHoverColor = await link.evaluate((el) => window.getComputedStyle(el).color); - await link.hover(); - // toHaveCSS retries automatically; waits for the 200ms transition to settle - await expect(link).not.toHaveCSS("color", preHoverColor); - const ratio = await getTextContrast(link); - expect(ratio, ".docs-ext-link hover (16px normal = normal text) must be >= 7:1 (WCAG AAA)").not.toBeNull(); - expect(ratio!).toBeGreaterThanOrEqual(7.0); - }); - - test("tag chip links (12px = normal text) hover >= 7:1", async ({ page }) => { - await page.addInitScript(() => localStorage.setItem("theme", "light")); - await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto("/adventures/blind-by-design/levels/beginner"); - await page.waitForLoadState("networkidle"); - const chip = page.locator(".tag-chip-link").first(); - await chip.hover(); - const ratio = await getTextContrast(chip); - expect(ratio, ".tag-chip-link hover (12px = normal text) must be >= 7:1 (WCAG AAA)").not.toBeNull(); - expect(ratio!).toBeGreaterThanOrEqual(7.0); - }); - - test("primary button .btn-primary (14px semibold = normal text) hover >= 7:1", async ({ page }) => { - await page.addInitScript(() => localStorage.setItem("theme", "light")); - await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto("/"); - await page.waitForLoadState("networkidle"); - const btn = page.getByRole("link", { name: /Start a Challenge/i }); - await btn.hover(); - const ratio = await getTextContrast(btn); - expect(ratio, "primary button hover (14px semibold = normal text) must be >= 7:1 (WCAG AAA)").not.toBeNull(); - expect(ratio!).toBeGreaterThanOrEqual(7.0); +test.describe("uniqueness", () => { + test("all titles are unique", () => { + const titles = Object.values(ROUTES); + expect(new Set(titles).size).toBe(titles.length); }); }); -test.describe("hydration and interactivity", () => { - test("theme toggle switches from dark to light", async ({ page }) => { +test.describe("island hydration", () => { + test("theme toggle hydrates and switches theme", async ({ page }) => { await page.goto("/"); - await expect(page.locator("html")).toHaveClass(/dark/); - - // Two buttons exist (desktop + mobile); target the desktop one - await page.getByRole("button", { name: "Switch to light mode" }).first().click(); + await page.waitForLoadState("load"); + const toggle = page.getByRole("button", { name: /switch to (light|dark) mode/i }); + await expect(toggle).toBeVisible(); + await toggle.click(); await expect(page.locator("html")).toHaveClass(/light/); }); - test("theme preference persists across page reload", async ({ page }) => { - await page.goto("/"); - await page.getByRole("button", { name: "Switch to light mode" }).first().click(); - await expect(page.locator("html")).toHaveClass(/light/); - - await page.reload(); - await expect(page.locator("html")).toHaveClass(/light/); - }); - - test("consent accept stores granted and replaces banner with preferences button", async ({ page }) => { - await page.goto("/"); - const banner = page.getByRole("region", { name: "This site uses analytics cookies" }); - await expect(banner).toBeVisible(); - - await page.getByRole("button", { name: "Accept analytics cookies" }).click(); - - await expect(banner).not.toBeVisible(); - await expect(page.getByRole("button", { name: "Cookie Preferences" })).toBeVisible(); - const stored = await page.evaluate(() => localStorage.getItem("analytics_consent")); - expect(JSON.parse(stored!).value).toBe("granted"); - }); - - test("consent decline stores denied and replaces banner with preferences button", async ({ page }) => { - await page.goto("/"); - await page.getByRole("button", { name: "Decline analytics cookies" }).click(); - - await expect(page.getByRole("region", { name: "This site uses analytics cookies" })).not.toBeVisible(); - await expect(page.getByRole("button", { name: "Cookie Preferences" })).toBeVisible(); - const stored = await page.evaluate(() => localStorage.getItem("analytics_consent")); - expect(JSON.parse(stored!).value).toBe("denied"); - }); - - test("client-side navigation updates URL and title without a full reload", async ({ page }) => { - await page.goto("/"); - await expect(page).toHaveTitle(/OffOn - Vendor-Neutral/); - - await page.getByRole("navigation", { name: "Main" }).getByRole("link", { name: "About" }).click(); - - await expect(page).toHaveURL(/\/about/); - await expect(page).toHaveTitle(/Building the contributors/); - await expect(page.locator("main#main-content")).toBeAttached(); - }); - - test("skip nav link is the first Tab stop", async ({ page }) => { - await page.goto("/"); - await page.keyboard.press("Tab"); - await expect(page.locator(":focus")).toContainText("Skip to main content"); - }); - - test("skip nav link moves focus to #main-content when activated", async ({ page }) => { - await page.goto("/"); - await page.keyboard.press("Tab"); - await expect(page.locator(":focus")).toContainText("Skip to main content"); - await page.keyboard.press("Enter"); - await expect(page.locator(":focus")).toHaveAttribute("id", "main-content"); + test("challenges filter hydrates and filters", async ({ page }) => { + await page.goto("/challenges/"); + await page.waitForLoadState("load"); + // Unfiltered: adventure cards shown, level results hidden. + await expect(page.locator('[data-results="adventures"]')).toBeVisible(); + await expect(page.locator('[data-results="levels"]')).toBeHidden(); + await page.getByRole("radio", { name: "Beginner", exact: true }).click(); + // Filtered: level cards shown, adventures hidden, URL reflects the difficulty. + await expect(page.locator('[data-results="levels"]')).toBeVisible(); + await expect(page.locator('[data-results="adventures"]')).toBeHidden(); + expect(await page.locator('[data-results="levels"] > li').count()).toBeGreaterThan(0); + expect(new URL(page.url()).searchParams.get("difficulty")).toBe("Beginner"); }); }); diff --git a/e2e/solution-hashchange.spec.ts b/e2e/solution-hashchange.spec.ts new file mode 100644 index 000000000..8dd972d88 --- /dev/null +++ b/e2e/solution-hashchange.spec.ts @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2025 OffOn contributors +// SPDX-License-Identifier: MIT + +// Regression: solution.astro registered a window hashchange listener inside +// astro:page-load without a corresponding astro:before-swap teardown. Each +// client-side navigation to the solution page added a new listener on top of +// survivors from previous visits. The fix captures the handler reference and +// removes it on before-swap. + +import { test, expect, type Page } from "@playwright/test"; + +const SOLUTION_URL = + "/adventures/echoes-lost-in-orbit/levels/beginner/solution/"; + +async function seedDenied(page: Page): Promise<void> { + await page.addInitScript(() => + localStorage.setItem( + "analytics_consent", + JSON.stringify({ value: "denied", timestamp: Date.now() }), + ), + ); +} + +test.describe("solution step hashchange listener lifecycle", () => { + test("hash in URL opens the matching step on load", async ({ page }) => { + await seedDenied(page); + await page.goto(SOLUTION_URL + "#two-applications"); + await page.waitForLoadState("load"); + await expect(page.locator("#two-applications")).toHaveAttribute("open", ""); + }); + + // Regression: without astro:before-swap teardown, each client-side navigation + // to the solution page stacks a new hashchange listener on window. The fix + // must remove the previous listener before adding the new one. + test("no hashchange listener accumulation across navigations", async ({ + page, + }) => { + // Instrument EventTarget.prototype before any page script runs. + await page.addInitScript(() => { + let added = 0; + let removed = 0; + const origAdd = EventTarget.prototype.addEventListener; + const origRemove = EventTarget.prototype.removeEventListener; + (EventTarget.prototype as unknown as Record<string, unknown>).addEventListener = + function ( + type: string, + listener: EventListenerOrEventListenerObject | null, + opts?: boolean | AddEventListenerOptions, + ) { + if (type === "hashchange" && (this as unknown as EventTarget) === window) + added++; + return origAdd.call(this, type, listener, opts); + }; + (EventTarget.prototype as unknown as Record<string, unknown>).removeEventListener = + function ( + type: string, + listener: EventListenerOrEventListenerObject | null, + opts?: boolean | EventListenerOptions, + ) { + if (type === "hashchange" && (this as unknown as EventTarget) === window) + removed++; + return origRemove.call(this, type, listener, opts); + }; + (window as unknown as Record<string, unknown>).__hashStats = () => ({ + added, + removed, + net: added - removed, + }); + }); + + await seedDenied(page); + + // Full load lands on the solution page; astro:page-load fires and registers + // the first hashchange listener. addInitScript counters reset here (full nav). + await page.goto(SOLUTION_URL); + await page.waitForLoadState("load"); + + const challengesLink = () => + page.getByRole("link", { name: "Challenges", exact: true }).first(); + + // Two round-trips: solution → challenges → solution. + // Each departure must tear down the listener; each return must add one. + for (let i = 0; i < 2; i++) { + await challengesLink().click(); + await page.waitForURL("**/challenges/"); + await page.waitForLoadState("networkidle"); + + await page.goBack(); + await page.waitForURL("**/solution/"); + await page.waitForLoadState("networkidle"); + } + + // Now on solution page for the third time. + const { removed, net } = await page.evaluate( + () => + ( + window as unknown as { + __hashStats: () => { removed: number; net: number }; + } + ).__hashStats(), + ); + + // Two departures must have fired teardown. + expect(removed, "teardown must fire on each astro:before-swap").toBeGreaterThanOrEqual(2); + // Exactly one listener active right now, regardless of visit count. + // With the bug (no teardown) this would be 3 after 2 extra returns. + expect(net, "only one hashchange listener active at any time").toBe(1); + }); +}); diff --git a/e2e/starter-nudge.spec.ts b/e2e/starter-nudge.spec.ts new file mode 100644 index 000000000..271d41302 --- /dev/null +++ b/e2e/starter-nudge.spec.ts @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: 2025 OffOn contributors +// SPDX-License-Identifier: MIT + +// Starter nudge: appears for new visitors, stays gone once dismissed. +// +// It points at the easiest level of the newest live adventure, or of the most +// recent adventure when nothing is live. Live is a preference, not a gate: +// gating on it meant the pointer vanished entirely once every deadline had +// passed, which is exactly when a new visitor still needs somewhere to start. + +import { test, expect, type Page } from "@playwright/test"; + +const NUDGE = "[data-starter-nudge]"; +const DISMISS = "[data-starter-nudge-dismiss]"; +const KEY = "starter_nudge_dismissed"; + +/** + * The adventure the nudge should point at, derived from the /adventures/ grid: + * cards are newest-first, so it is the first one carrying a Live pill, else the + * first card. Computed from the page rather than hardcoded, so the expectation + * follows the content instead of going stale when a deadline passes. + */ +async function expectedStarterSlug(page: Page): Promise<{ slug: string; live: boolean }> { + await page.goto("/adventures/"); + await page.waitForLoadState("load"); + + // Grid cards only (`.card-glow`), in DOM order, which is newest-first. + // Liveness comes from the LivePill element, not its text: the label is + // lowercase in source and uppercased by CSS, and it sits flush against + // neighbouring words in textContent. + const cards = await page.evaluate(() => + Array.from(document.querySelectorAll<HTMLAnchorElement>('a.card-glow[href*="/adventures/"]')) + .filter((a) => /\/adventures\/[^/]+\/$/.test(new URL(a.href).pathname)) + .map((a) => ({ + slug: new URL(a.href).pathname.split("/adventures/")[1].replace("/", ""), + live: !!a.querySelector("[data-live-pill]"), + })), + ); + + const live = cards.find((c) => c.live); + return { slug: (live ?? cards[0]).slug, live: !!live }; +} + +test("renders regardless of whether any adventure is live", async ({ page }) => { + const anyLive = (await page.getByText("Live", { exact: true }).count()) > 0; + + await page.goto("/"); + await page.waitForLoadState("load"); + + await expect( + page.locator(NUDGE), + `nudge must render whether or not an adventure is live (live: ${anyLive})`, + ).toBeVisible(); +}); + +test("points at the newest live adventure, or the most recent when none is live", async ({ + page, +}) => { + const { slug, live } = await expectedStarterSlug(page); + + await page.goto("/"); + const href = await page.locator(`${NUDGE} a`).getAttribute("href"); + expect( + href, + live ? `expected the newest live adventure (${slug})` : `nothing live, expected the most recent (${slug})`, + ).toContain(`/adventures/${slug}/`); +}); + +test("points at the easiest level of that adventure", async ({ page }) => { + await page.goto("/"); + const href = await page.locator(`${NUDGE} a`).getAttribute("href"); + expect(href).toMatch(/\/levels\/beginner\/$/); +}); + +test.describe("behaviour", () => { + for (const path of ["/", "/challenges/"]) { + test(`${path}: shows for a new visitor and links to the starter level`, async ({ page }) => { + await page.goto(path); + await expect(page.locator(NUDGE)).toBeVisible(); + await expect(page.locator(`${NUDGE} a`)).toHaveAttribute( + "href", + /\/adventures\/.+\/levels\/.+\//, + ); + }); + + test(`${path}: stays hidden once dismissed`, async ({ page }) => { + await page.addInitScript((k) => localStorage.setItem(k, "1"), KEY); + await page.goto(path); + await page.waitForLoadState("load"); + await expect(page.locator(NUDGE)).toBeHidden(); + }); + } + + test("dismissing hides it and persists across a reload", async ({ page }) => { + await page.goto("/"); + await expect(page.locator(NUDGE)).toBeVisible(); + + await page.locator(DISMISS).click(); + await expect(page.locator(NUDGE)).toBeHidden(); + expect(await page.evaluate((k) => localStorage.getItem(k), KEY)).toBe("1"); + + await page.reload(); + await page.waitForLoadState("load"); + await expect(page.locator(NUDGE)).toBeHidden(); + }); + + test("is inside an atomic live region so it is announced on reveal", async ({ page }) => { + await page.goto("/"); + const live = page.locator('[aria-live="polite"]').filter({ has: page.locator(NUDGE) }); + await expect(live).toHaveAttribute("aria-atomic", "true"); + }); + + test("survives a client-side navigation", async ({ page }) => { + await page.goto("/"); + await expect(page.locator(NUDGE)).toBeVisible(); + await page.getByRole("link", { name: "Challenges", exact: true }).first().click(); + await page.waitForURL("**/challenges/"); + await expect(page.locator(NUDGE)).toBeVisible(); + }); +}); diff --git a/e2e/teardown.ts b/e2e/teardown.ts new file mode 100644 index 000000000..a74fe77b6 --- /dev/null +++ b/e2e/teardown.ts @@ -0,0 +1,5 @@ +import { execSync } from "child_process"; + +export default function () { + execSync("astro preview stop", { stdio: "ignore" }); +} diff --git a/e2e/theme-toggle.spec.ts b/e2e/theme-toggle.spec.ts new file mode 100644 index 000000000..8983331c5 --- /dev/null +++ b/e2e/theme-toggle.spec.ts @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: 2025 OffOn contributors +// SPDX-License-Identifier: MIT + +// Theme switch behaviour. +// +// The icon and the accessible name are driven by CSS off the `.dark` class on +// <html>, so they must already be right in the first painted frame for a +// returning light-mode visitor, before any script runs. The previous island +// rendered the dark defaults server-side and only corrected them after +// hydration, which showed the wrong icon and announced the wrong name in +// between. That regression is what the "before any script" cases below cover. + +import { test, expect, type Page } from "@playwright/test"; + +const TOGGLE = "[data-theme-toggle]"; + +/** Accessible name of every visible toggle on the page. */ +async function visibleToggleNames(page: Page): Promise<string[]> { + return page.evaluate((sel) => + Array.from(document.querySelectorAll<HTMLElement>(sel)) + .filter((el) => el.offsetParent !== null) + .map((el) => + Array.from(el.querySelectorAll("span")) + .filter((s) => getComputedStyle(s).display !== "none") + .map((s) => s.textContent?.trim() ?? "") + .join(" ") + .trim(), + ), TOGGLE); +} + +/** Which icon is actually displayed, by its distinguishing path data. */ +async function visibleIcons(page: Page): Promise<string[]> { + return page.evaluate((sel) => + Array.from(document.querySelectorAll<HTMLElement>(sel)) + .filter((el) => el.offsetParent !== null) + .flatMap((el) => + Array.from(el.querySelectorAll("svg")) + .filter((s) => getComputedStyle(s).display !== "none") + .map((s) => (s.querySelector("circle") ? "sun" : "moon")), + ), TOGGLE); +} + +test.describe("correct before any script runs", () => { + for (const [stored, icon, name] of [ + ["dark", "sun", "Switch to light mode"], + ["light", "moon", "Switch to dark mode"], + ] as const) { + test(`stored "${stored}" theme shows the ${icon} icon and the right name with JS disabled`, async ({ + browser, + }) => { + // With scripting off the inline pre-paint script never runs, so rewrite + // the class it would have set and assert CSS alone resolves the control. + const context = await browser.newContext({ javaScriptEnabled: false }); + const page = await context.newPage(); + await page.route("**/*", async (route) => { + const res = await route.fetch(); + if (!res.headers()["content-type"]?.includes("text/html")) return route.fulfill({ response: res }); + let body = await res.text(); + if (stored === "light") body = body.replace('<html lang="en" class="dark">', '<html lang="en" class="light">'); + return route.fulfill({ response: res, body }); + }); + + await page.goto("/"); + expect(await visibleIcons(page)).toContain(icon); + expect(await visibleToggleNames(page)).toContain(name); + await context.close(); + }); + } + + test("a returning light-mode visitor never sees the dark icon", async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("theme", "light")); + await page.goto("/"); + // The inline pre-paint script has run; no island hydration is involved. + await expect(page.locator("html")).toHaveClass(/light/); + expect(await visibleIcons(page)).not.toContain("sun"); + expect(await visibleToggleNames(page)).toContain("Switch to dark mode"); + }); +}); + +test.describe("toggling", () => { + test("switches the theme, the icon and the name together", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("html")).toHaveClass(/dark/); + expect(await visibleToggleNames(page)).toContain("Switch to light mode"); + + await page.locator(TOGGLE).filter({ visible: true }).first().click(); + + await expect(page.locator("html")).toHaveClass(/light/); + expect(await visibleIcons(page)).toContain("moon"); + expect(await visibleToggleNames(page)).toContain("Switch to dark mode"); + }); + + test("persists across a reload", async ({ page }) => { + await page.goto("/"); + await page.locator(TOGGLE).filter({ visible: true }).first().click(); + await expect(page.locator("html")).toHaveClass(/light/); + expect(await page.evaluate(() => localStorage.getItem("theme"))).toBe("light"); + + await page.reload(); + await expect(page.locator("html")).toHaveClass(/light/); + expect(await visibleToggleNames(page)).toContain("Switch to dark mode"); + }); + + test("persists across a client-side navigation", async ({ page }) => { + await page.goto("/"); + await page.locator(TOGGLE).filter({ visible: true }).first().click(); + await expect(page.locator("html")).toHaveClass(/light/); + + await page.getByRole("link", { name: "Challenges", exact: true }).first().click(); + await page.waitForURL("**/challenges/"); + await expect(page.locator("html")).toHaveClass(/light/); + expect(await visibleToggleNames(page)).toContain("Switch to dark mode"); + }); + + test("still works after a client-side navigation", async ({ page }) => { + await page.goto("/"); + await page.getByRole("link", { name: "Challenges", exact: true }).first().click(); + await page.waitForURL("**/challenges/"); + + await page.locator(TOGGLE).filter({ visible: true }).first().click(); + await expect(page.locator("html")).toHaveClass(/light/); + }); + + test("announces the change in the live region", async ({ page }) => { + await page.goto("/"); + await page.locator(TOGGLE).filter({ visible: true }).first().click(); + await expect(page.locator("#theme-status")).toHaveText("Theme switched to light mode"); + }); +}); + +test.describe("both instances agree", () => { + test("desktop and mobile toggles render the same state", async ({ page }) => { + await page.goto("/"); + // Both exist in the DOM at any width; only one is visible per breakpoint. + const states = await page.evaluate((sel) => + Array.from(document.querySelectorAll<HTMLElement>(sel)).map((el) => + Array.from(el.querySelectorAll("span")) + .filter((s) => getComputedStyle(s).display !== "none") + .map((s) => s.textContent?.trim()) + .join(""), + ), TOGGLE); + expect(states.length).toBe(2); + expect(new Set(states).size, `toggles disagree: ${JSON.stringify(states)}`).toBe(1); + }); + + test("toggling at one breakpoint is reflected at the other", async ({ page }) => { + await page.setViewportSize({ width: 1400, height: 900 }); + await page.goto("/"); + await page.locator(TOGGLE).filter({ visible: true }).first().click(); + await expect(page.locator("html")).toHaveClass(/light/); + + // Cross to the mobile breakpoint: the other instance must already agree, + // with no state handed between them. + await page.setViewportSize({ width: 390, height: 780 }); + expect(await visibleToggleNames(page)).toEqual(["Switch to dark mode"]); + expect(await visibleIcons(page)).toEqual(["moon"]); + }); +}); diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts deleted file mode 100644 index 8f6aef2c7..000000000 --- a/e2e/visual.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Visual regression tests. Run `npm run build` before `npm run test:visual`. -// First run generates baseline screenshots in e2e/__screenshots__/. -// Subsequent runs compare against baselines and fail if diffs exceed threshold. -// Thresholds are set globally in playwright.config.ts. -// -// These tests are intentionally local-only. They are not run in CI or on PR -// previews because screenshot rendering differs between macOS and Linux, making -// cross-platform comparison unreliable. Run them manually before and after any -// major layout or design change to catch visual regressions on your own machine. - -import { test, expect, type Page } from "@playwright/test"; - -type VisualRoute = { - path: string; - name: string; - maskSelectors?: string[]; -}; - -const VISUAL_ROUTES: VisualRoute[] = [ - { path: "/", name: "home" }, - { path: "/adventures", name: "adventures" }, - { path: "/adventures/blind-by-design", name: "adventure-detail" }, - { - path: "/adventures/blind-by-design/levels/beginner", - name: "challenge-detail", - maskSelectors: [".timestamp", "[data-discussion-posts]"], - }, - { path: "/challenges", name: "challenges-grid" }, - { path: "/about", name: "about" }, - { path: "/404", name: "404" }, -]; - -// CSS that hides the floating cookie preferences button, which renders whenever -// consent is non-null. We keep the button out of screenshots because it is a -// fixed overlay whose position is unrelated to page content. -// The selector mirrors the button's aria-label, which is also asserted in -// smoke.spec.ts, so a rename surfaces in both places at once. -const CONSENT_BUTTON_CSS = `[aria-label="Cookie Preferences"] { display: none !important; }`; - -// Registers a script to run before React mounts on each navigation. -// Pre-denying consent means useConsent restores "denied" from localStorage, -// so the banner never renders. The floating button still renders (consent != -// null) and is hidden via CONSENT_BUTTON_CSS after navigation. -// -// "analytics_consent" is CONSENT_STORAGE_KEY from src/data/constants.ts. -// Hardcoded here because addInitScript runs in browser context without imports. -async function denyConsent(page: Page): Promise<void> { - await page.addInitScript(() => { - localStorage.setItem( - "analytics_consent", - JSON.stringify({ value: "denied", timestamp: Date.now() }), - ); - }); -} - -test.describe("visual regression (dark mode)", () => { - for (const { path, name, maskSelectors } of VISUAL_ROUTES) { - test(name, async ({ page }) => { - await denyConsent(page); - await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto(path); - await page.waitForLoadState("networkidle"); - await page.addStyleTag({ content: CONSENT_BUTTON_CSS }); - - const mask = maskSelectors?.map((s) => page.locator(s)) ?? []; - - await expect(page).toHaveScreenshot(`${name}-dark.png`, { - fullPage: true, - mask, - }); - }); - } -}); - -test.describe("visual regression (light mode)", () => { - for (const { path, name, maskSelectors } of VISUAL_ROUTES) { - test(name, async ({ page }) => { - await denyConsent(page); - await page.addInitScript(() => localStorage.setItem("theme", "light")); - await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto(path); - await page.waitForLoadState("networkidle"); - await expect(page.locator("html")).toHaveClass(/light/); - await page.addStyleTag({ content: CONSENT_BUTTON_CSS }); - - const mask = maskSelectors?.map((s) => page.locator(s)) ?? []; - - await expect(page).toHaveScreenshot(`${name}-light.png`, { - fullPage: true, - mask, - }); - }); - } -}); - -// Dedicated consent banner regression. No consent is pre-set so the banner -// renders in its natural state. Viewport-only (fullPage: false) so the banner -// is clearly visible at the bottom of the frame rather than lost at the bottom -// of a multi-screen full-page capture. -test.describe("visual regression — consent banner", () => { - test("consent banner (dark mode)", async ({ page }) => { - await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto("/"); - await page.waitForLoadState("networkidle"); - await expect( - page.getByRole("region", { name: "This site uses analytics cookies" }), - ).toBeVisible(); - await expect(page).toHaveScreenshot("consent-banner-dark.png", { - fullPage: false, - }); - }); - - test("consent banner (light mode)", async ({ page }) => { - await page.addInitScript(() => localStorage.setItem("theme", "light")); - await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto("/"); - await page.waitForLoadState("networkidle"); - await expect(page.locator("html")).toHaveClass(/light/); - await expect( - page.getByRole("region", { name: "This site uses analytics cookies" }), - ).toBeVisible(); - await expect(page).toHaveScreenshot("consent-banner-light.png", { - fullPage: false, - }); - }); -}); diff --git a/e2e/wsg.spec.ts b/e2e/wsg.spec.ts deleted file mode 100644 index 43990761c..000000000 --- a/e2e/wsg.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -// Web Sustainability Guidelines (WSG) automated checks. -// Requires a production build in dist/client/. Run `npm run build` before `npm run test:e2e`. - -import { test, expect } from "@playwright/test"; - -const PAGES = [ - "/", - "/adventures", - "/challenges", - // Representative detail pages. Catch weight/media regressions on content-heavy routes. - "/adventures/blind-by-design/levels/beginner", - "/challenges/opentelemetry", -]; - -// Total compressed bytes transferred on first load (no cache). -// Adjust after running: look for "transferred X KB" in failure output. -const PAGE_WEIGHT_BUDGET_KB = 750; - -// --------------------------------------------------------------------------- -// Page weight (WSG 2.3, 2.5) -// --------------------------------------------------------------------------- - -test.describe("WSG: page weight", () => { - for (const path of PAGES) { - test(`${path} total transfer < ${PAGE_WEIGHT_BUDGET_KB} KB`, async ({ page, context }) => { - const client = await context.newCDPSession(page); - await client.send("Network.enable"); - - let totalBytes = 0; - client.on("Network.loadingFinished", (event) => { - totalBytes += event.encodedDataLength; - }); - - await page.goto(path); - await page.waitForLoadState("networkidle"); - - const kb = Math.round(totalBytes / 1024); - expect( - totalBytes, - `${path} transferred ${kb} KB, over ${PAGE_WEIGHT_BUDGET_KB} KB budget`, - ).toBeLessThan(PAGE_WEIGHT_BUDGET_KB * 1024); - }); - } -}); - -// --------------------------------------------------------------------------- -// Third-party requests (WSG 5.14, 5.15) -// No analytics or tracking requests without consent. -// Community avatar hosts (community.offon.dev, discourse-cdn.com) are -// allowlisted, as they serve first-party content, not tracking scripts. -// --------------------------------------------------------------------------- - -// Hosts that are permitted to receive requests on every page load. -// Add only hosts that serve first-party content, not analytics or ads. -const ALLOWED_EXTERNAL_HOSTS = [ - "community.offon.dev", - "avatars.discourse-cdn.com", - "sea2.discourse-cdn.com", -]; - -function isAllowedHost(hostname: string): boolean { - return ( - hostname === "localhost" || - hostname === "127.0.0.1" || - ALLOWED_EXTERNAL_HOSTS.some((h) => hostname === h || hostname.endsWith(`.${h}`)) - ); -} - -test.describe("WSG: third-party requests", () => { - for (const path of PAGES) { - test(`${path} makes no tracking/analytics requests without consent`, async ({ page }) => { - const unexpected: string[] = []; - - page.on("request", (request) => { - try { - const { hostname } = new URL(request.url()); - if (!isAllowedHost(hostname)) { - unexpected.push(request.url()); - } - } catch { - // ignore non-http requests (e.g. data:) - } - }); - - await page.goto(path); - await page.waitForLoadState("networkidle"); - - expect(unexpected, `${path} made unexpected third-party requests`).toHaveLength(0); - }); - } -}); - -// --------------------------------------------------------------------------- -// Image optimisation (WSG 2.9) -// --------------------------------------------------------------------------- - -test.describe("WSG: image optimisation", () => { - for (const path of PAGES) { - test(`${path}: all images have explicit width and height`, async ({ page }) => { - await page.goto(path); - await page.waitForLoadState("networkidle"); - - const violations = await page.evaluate((): string[] => - Array.from(document.querySelectorAll("img")) - .filter((img) => !img.hasAttribute("width") || !img.hasAttribute("height")) - .map((img) => img.outerHTML.slice(0, 120)), - ); - - expect(violations, "Images missing explicit width/height (causes CLS)").toHaveLength(0); - }); - - test(`${path}: below-fold images use loading="lazy"`, async ({ page }) => { - await page.goto(path); - await page.waitForLoadState("networkidle"); - - const violations = await page.evaluate((): string[] => - Array.from(document.querySelectorAll("img")) - .filter((img) => { - const belowFold = img.getBoundingClientRect().top >= window.innerHeight; - return belowFold && img.loading !== "lazy"; - }) - .map((img) => img.outerHTML.slice(0, 120)), - ); - - expect(violations, "Below-fold images missing loading='lazy'").toHaveLength(0); - }); - } -}); - -// --------------------------------------------------------------------------- -// Media (WSG 2.14) -// Autoplaying media wastes bandwidth and energy without user intent. -// --------------------------------------------------------------------------- - -test.describe("WSG: media", () => { - for (const path of PAGES) { - test(`${path}: no autoplaying media without muted`, async ({ page }) => { - await page.goto(path); - await page.waitForLoadState("networkidle"); - - const violations = await page.evaluate((): string[] => - Array.from(document.querySelectorAll("video[autoplay], audio[autoplay]")) - .filter((el) => !el.hasAttribute("muted")) - .map((el) => el.outerHTML.slice(0, 120)), - ); - - expect(violations, "Autoplaying media without muted attribute").toHaveLength(0); - }); - } -}); diff --git a/eslint.config.js b/eslint.config.js index 11515694b..6b6d13ba9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,43 +1,79 @@ import js from "@eslint/js"; -import globals from "globals"; -import reactHooks from "eslint-plugin-react-hooks"; -import reactRefresh from "eslint-plugin-react-refresh"; -import jsxA11y from "eslint-plugin-jsx-a11y"; import tseslint from "typescript-eslint"; +import astro from "eslint-plugin-astro"; +import pluginVue from "eslint-plugin-vue"; +import vueA11y from "eslint-plugin-vuejs-accessibility"; +import globals from "globals"; export default tseslint.config( - { ignores: ["dist", "coverage", "src/components/ui/**", ".react-router/types/**", "public/reveal/**"] }, { - extends: [ - js.configs.recommended, - ...tseslint.configs.recommended, - jsxA11y.flatConfigs.recommended, + ignores: [ + "dist/", + ".astro/", + "node_modules/", + "coverage/", + // Playwright creates this directory only after an e2e run; it is gitignored + // but ESLint's tree walk happens before ignores are applied, so it must be + // listed here to prevent an ENOENT crash on fresh checkouts. + "test-results/", + // Static/vendored assets served as-is (reveal.js, decks, minified libs). + "public/", + // Presentation-template generators are standalone Node scripts. + ".claude/", ], - files: ["**/*.{ts,tsx}"], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + ...pluginVue.configs["flat/recommended"], + ...vueA11y.configs["flat/recommended"], + ...astro.configs.recommended, + // Static accessibility rules for .astro templates. Astro maps the jsx-a11y + // ruleset onto its own template syntax; without this nothing checks markup in + // .astro files, which is most of the site. + ...astro.configs["flat/jsx-a11y-recommended"], + { languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - plugins: { - "react-hooks": reactHooks, - "react-refresh": reactRefresh, + globals: { ...globals.browser, ...globals.node }, }, + }, + { + // Vue SFC <script lang="ts"> is parsed by the TS parser. + files: ["**/*.vue"], + languageOptions: { parserOptions: { parser: tseslint.parser } }, + }, + { rules: { - ...reactHooks.configs.recommended.rules, - "react-refresh/only-export-components": ["warn", { - allowConstantExport: true, - allowExportNames: ["meta", "links", "loader", "clientLoader", "action", "clientAction", "headers", "handle", "shouldRevalidate"], - }], - "@typescript-eslint/no-unused-vars": ["warn", { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_" - }], - // Safari VoiceOver strips list semantics when list-style is removed (Tailwind list-none). - // Explicit role="list" on <ul>/<ol> restores them and is intentional throughout the codebase - // (30+ instances). Allow that one redundant role, but keep the rule on so other redundant - // roles (e.g. role="button" on <button>) are caught. Note: the rule does not flag redundant - // landmark roles like role="navigation" on <nav>; those stay covered by ACCESSIBILITY.md. - "jsx-a11y/no-redundant-roles": ["error", { ul: ["list"], ol: ["list"] }], + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], + // The content pipeline / loaders use loosely-typed data; allow explicit any there. + "@typescript-eslint/no-explicit-any": "off", + // Purely stylistic Vue template formatting — handled by the editor, not lint. + "vue/max-attributes-per-line": "off", + "vue/singleline-html-element-content-newline": "off", + "vue/html-self-closing": "off", + "vue/html-closing-bracket-newline": "off", + "vue/first-attribute-linebreak": "off", + // set:html/v-html render pre-sanitised, build-time author prose (see CLAUDE.md). + "vue/no-v-html": "off", + + // Safari VoiceOver strips list semantics when list-style is removed, which + // Tailwind's reset does everywhere. Explicit role="list" on <ul>/<ol> + // restores them and is intentional throughout. Allow that one redundant + // role and keep the rule on, so role="button" on a <button> is still caught. + "astro/jsx-a11y/no-redundant-roles": ["error", { ul: ["list"], ol: ["list"] }], + + // <abbr> is a tooltip trigger here: it must be focusable or the expansion + // is mouse-only (WCAG 2.1.1). Allow tabindex on abbr specifically; every + // other non-interactive element stays flagged. + // + // <pre> is the other case: a horizontally scrollable code block is not + // keyboard-scrollable unless it is focusable (WCAG 2.1.1). + "astro/jsx-a11y/no-noninteractive-tabindex": [ + "error", + { tags: ["abbr", "pre"], roles: ["tabpanel"], allowExpressionValues: true }, + ], + + // Same Safari list-semantics reasoning as the Astro rule above. + "vuejs-accessibility/no-redundant-roles": ["error", { ul: ["list"], ol: ["list"] }], }, }, ); diff --git a/package-lock.json b/package-lock.json index 9878910b9..65ede3d01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,129 +1,470 @@ { "name": "offon-website", - "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "offon-website", - "version": "0.0.0", - "license": "MIT", "dependencies": { - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "lucide-react": "^1.27.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-router": "^8.3.0", - "tailwind-merge": "^3.5.0" - }, - "devDependencies": { - "@axe-core/playwright": "^4.12.1", - "@eslint/js": "^10.0.1", - "@playwright/test": "^1.62.0", - "@react-router/dev": "^8.3.0", + "@astrojs/vue": "^7.0.1", + "@iconify-json/lucide": "^1.2.118", "@tailwindcss/vite": "^4.3.3", - "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^7.0.0", - "@testing-library/react": "^16.0.0", - "@types/node": "^26.0.1", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.1.0", - "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-v8": "^4.1.5", - "ajv": "^8.20.0", - "eslint": "^10.8.0", - "eslint-plugin-jsx-a11y": "^6.10.2", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.8.0", - "jsdom": "^29.1.1", - "jszip": "^3.10.1", + "astro": "^7.1.3", + "nanostores": "^1.4.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", - "reveal.js": "^6.0.1", + "shiki": "^4.4.3", "tailwindcss": "^4.3.3", - "typescript": "^6.0.3", - "typescript-eslint": "^8.64.0", "unified": "^11.0.5", - "vite": "^8.1.5", - "vitest": "^4.1.5", + "unplugin-icons": "^0.22.0", + "vue": "^3.5.40", "yaml": "^2.9.0" }, + "devDependencies": { + "@astrojs/check": "^0.9.10", + "@axe-core/playwright": "^4.12.1", + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.62.0", + "@vitejs/plugin-vue": "^6.0.8", + "@vitest/coverage-v8": "^4.1.11", + "@vue/test-utils": "^2.4.6", + "eslint": "^10.8.0", + "eslint-plugin-astro": "^3.0.1", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-vue": "^10.10.0", + "eslint-plugin-vuejs-accessibility": "^2.6.0", + "globals": "^17.8.0", + "happy-dom": "^20.11.6", + "jszip": "^3.10.1", + "typescript": "^6.0.3", + "typescript-eslint": "^8.65.0", + "vitest": "^4.1.11", + "vue-eslint-parser": "^10.4.1" + }, "engines": { "node": ">=26.0.0" } }, - "node_modules/@adobe/css-tools": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", - "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", - "dev": true, + "node_modules/@antfu/install-pkg": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-0.5.0.tgz", + "integrity": "sha512-dKnk2xlAyC7rvTkpkHmu+Qy/2Zc3Vm/l8PtNyIOGDBtXPY3kThfU4ORNEp3V7SXw5XSOb+tOJaUYpfquPzL/Tg==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^0.2.5", + "tinyexec": "^0.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@antfu/install-pkg/node_modules/package-manager-detector": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", + "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", + "license": "MIT", + "dependencies": { + "quansync": "^0.2.7" + } + }, + "node_modules/@antfu/install-pkg/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "license": "MIT" }, - "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "node_modules/@antfu/utils": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-0.7.10.tgz", + "integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@astrojs/check": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.10.tgz", + "integrity": "sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@astrojs/language-server": "^2.16.7", + "chokidar": "^4.0.3", + "kleur": "^4.1.5", + "yargs": "^18.0.0" }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "bin": { + "astro-check": "bin/astro-check.js" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^6.0.0" } }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", - "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "node_modules/@astrojs/check/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1" + "readdirp": "^4.0.1" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "node_modules/@astrojs/check/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, "license": "MIT", "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "node_modules/@astrojs/compiler": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz", + "integrity": "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==", "dev": true, "license": "MIT" }, + "node_modules/@astrojs/compiler-binding": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.3.2.tgz", + "integrity": "sha512-8w/9CWmYrAJJ8N0SY3O43ws2BgxoW6u3QsD8u2mE140lMYAlwh+tlNoUeSBq22wVheFuiBbR212l6ixZ2IIgCQ==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@astrojs/compiler-binding-darwin-arm64": "0.3.2", + "@astrojs/compiler-binding-darwin-x64": "0.3.2", + "@astrojs/compiler-binding-linux-arm64-gnu": "0.3.2", + "@astrojs/compiler-binding-linux-arm64-musl": "0.3.2", + "@astrojs/compiler-binding-linux-x64-gnu": "0.3.2", + "@astrojs/compiler-binding-linux-x64-musl": "0.3.2", + "@astrojs/compiler-binding-wasm32-wasi": "0.3.2", + "@astrojs/compiler-binding-win32-arm64-msvc": "0.3.2", + "@astrojs/compiler-binding-win32-x64-msvc": "0.3.2" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-arm64": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.3.2.tgz", + "integrity": "sha512-MM8tn8CSimcfytaOla4b6acN8mKWiL/rlAA1fpT3/Wl7dNGSE4y8FjTN/zJVNnb63CsLWG5zZwCt01TXtDKh9g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-x64": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.3.2.tgz", + "integrity": "sha512-2lXOlzf8xb7jLomRsf/aswh61/NnGusynB2OwFkK6k4pmOtpfXMYnG0PLfXrEvxXYj69NdCnmUYXtHDd+JOOag==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-gnu": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.3.2.tgz", + "integrity": "sha512-BmU3kWj7qnLrd4vzm49zFEPJ5oFnn1tCT4Vt9hZbqdU5Cmb8GZl7fn6VFsnNfe7B18a2gIFtVzbLINtYl5kBjQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-musl": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.3.2.tgz", + "integrity": "sha512-f0heT9ZZEseSu5bHCeb80eL2DH07ArE6U9xi1WT/PEusNjzPmEr3GJsjG1tRLo5VYUUYX7h3ScaqGmGrMOVGmw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-gnu": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.3.2.tgz", + "integrity": "sha512-M8fOUt0itRpqiGyoEA/ij184s8O+hqbCz3+YozRusOOM3osgGljpDThhbKAJjqh82wOo6FioQ4w8PBvU1XMD5Q==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-musl": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.3.2.tgz", + "integrity": "sha512-/Kebk8sO6HnLeSd691JkaAPfN7CqR9/KEXmWvyNPkaKNGmj8rTZ/lf2uXtnPu92Lan84UrKdIKVPy1fSo2encQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-wasm32-wasi": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.3.2.tgz", + "integrity": "sha512-pUA6xbcOSB7DhfzIArB8BCAkFfAIqriiR7zl5zOStd6oU2G0kIKj+GUdGnyXyhfiv881Hffyk5tC0mR18sDjDw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.3.2.tgz", + "integrity": "sha512-ESruf+6Qkl1trHUFxI6GSf6t52j8yN2kCNSzMWdzt7V/T09tFHrYzrVaJQohb2C9bJUH76pNvX6Zb51+xCQc9Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-x64-msvc": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.3.2.tgz", + "integrity": "sha512-wzzVrEbOwbsLWOdEbocskjMRx2aZPxJ7ZbmL+jnpBamFwmigm+2M/wzuM6JWncocgYwLic1csSpalBh96kQKXA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-rs": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.3.2.tgz", + "integrity": "sha512-xlx/T7JovIKduu4ucbTQUxQ5+Q8wxkHxhLjnZk3VlJbhbQ9RLvvuDk1p2YYFYFQ5y14dVm3FGGO4isQXa4F+Tg==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler-binding": "0.3.2" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.4.tgz", + "integrity": "sha512-nozZSy/mKYLqe4YrqbKtdOszedAfXYCtw3wZ0d+CAjz4GqQ4L9rl1ltIL5BlgwmYVinJg/RZ0MgGuWOdlyRZlA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.3.0", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } + }, + "node_modules/@astrojs/language-server": { + "version": "2.16.14", + "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.14.tgz", + "integrity": "sha512-YPXkBu6N4d1sT09pvBmIDGZay+1MemV551FSgdEM3aZRDzbkxd2H7Cvf8MsVJLVB1mIEyTf1XbMN/30gp7s46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^2.13.1", + "@astrojs/yaml2ts": "^0.2.4", + "@jridgewell/sourcemap-codec": "^1.5.5", + "@volar/kit": "~2.4.28", + "@volar/language-core": "~2.4.28", + "@volar/language-server": "~2.4.28", + "@volar/language-service": "~2.4.28", + "muggle-string": "^0.4.1", + "tinyglobby": "^0.2.16", + "volar-service-css": "0.0.71", + "volar-service-emmet": "0.0.71", + "volar-service-html": "0.0.71", + "volar-service-prettier": "0.0.71", + "volar-service-typescript": "0.0.71", + "volar-service-typescript-twoslash-queries": "0.0.71", + "volar-service-yaml": "0.0.71", + "vscode-html-languageservice": "^5.6.2", + "vscode-uri": "^3.1.0" + }, + "bin": { + "astro-ls": "bin/nodeServer.js" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "prettier-plugin-astro": ">=0.11.0" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + } + } + }, + "node_modules/@astrojs/markdown-satteri": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.3.7.tgz", + "integrity": "sha512-NHcHbrKW/opbZnTZQ5nH293BdcK2VV0tjuzI88CLnvy2njiEJVwUQ5KFnYd4NoWgHJCY/I1CyjGWCR4Vv3khXQ==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.10.4", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "satteri": "^0.10.3" + } + }, + "node_modules/@astrojs/prism": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", + "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz", + "integrity": "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^4.4.0", + "dset": "^3.1.4", + "is-docker": "^4.0.0", + "package-manager-detector": "^1.6.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@astrojs/vue": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/vue/-/vue-7.0.2.tgz", + "integrity": "sha512-5vKGat4wYyN7l71iGF9cA79GeuLtPVoL+4b7NPQ7scKyq8HQp37XTXNqjV1v+lYsCW4Tmu0S2gH4453KbrTyaQ==", + "license": "MIT", + "dependencies": { + "@vitejs/plugin-vue": "^6.0.5", + "@vitejs/plugin-vue-jsx": "^5.1.5", + "@vue/compiler-sfc": "^3.5.30", + "vite": "^8.0.13", + "vite-plugin-vue-devtools": "^8.1.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + }, + "peerDependencies": { + "astro": "^7.0.0", + "vue": "^3.5.24" + } + }, + "node_modules/@astrojs/yaml2ts": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.4.tgz", + "integrity": "sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.8.3" + } + }, "node_modules/@axe-core/playwright": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", - "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", "dev": true, "license": "MPL-2.0", "dependencies": { - "axe-core": "~4.12.1" + "axe-core": "~4.13.0" }, "peerDependencies": { "playwright-core": ">= 1.0.0" @@ -133,7 +474,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -148,7 +488,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -158,7 +497,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -185,25 +523,14 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -216,7 +543,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -229,7 +555,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.29.7", @@ -242,21 +567,10 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", @@ -274,21 +588,10 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -298,7 +601,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.7", @@ -312,7 +614,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.7", @@ -326,7 +627,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.29.7", @@ -344,7 +644,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -357,7 +656,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -367,7 +665,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", @@ -385,7 +682,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.7", @@ -399,7 +695,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -409,7 +704,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -419,7 +713,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -429,7 +722,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.29.7", @@ -440,13 +732,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -455,14 +746,15 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-syntax-jsx": { + "node_modules/@babel/plugin-proposal-decorators": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", - "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", - "dev": true, + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.7.tgz", + "integrity": "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-decorators": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -471,11 +763,10 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-typescript": { + "node_modules/@babel/plugin-syntax-decorators": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", - "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", - "dev": true, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.29.7.tgz", + "integrity": "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -487,14 +778,12 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-modules-commonjs": { + "node_modules/@babel/plugin-syntax-import-attributes": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", - "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", - "dev": true, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { @@ -504,18 +793,25 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-typescript": { + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", - "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", - "dev": true, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-create-class-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", - "@babel/plugin-syntax-typescript": "^7.29.7" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -524,18 +820,13 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/preset-typescript": { + "node_modules/@babel/plugin-syntax-typescript": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", - "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", - "dev": true, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "@babel/plugin-syntax-jsx": "^7.29.7", - "@babel/plugin-transform-modules-commonjs": "^7.29.7", - "@babel/plugin-transform-typescript": "^7.29.7" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -544,21 +835,29 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/runtime": { + "node_modules/@babel/plugin-transform-typescript": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -570,18 +869,17 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -589,10 +887,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -612,711 +909,439 @@ "node": ">=18" } }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, + "node_modules/@bruits/satteri-darwin-arm64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", + "integrity": "sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@csstools/color-helpers": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", - "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "node_modules/@bruits/satteri-darwin-x64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.10.5.tgz", + "integrity": "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==", + "cpu": [ + "x64" ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" - } + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@csstools/css-calc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", - "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "node_modules/@bruits/satteri-linux-arm64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.10.5.tgz", + "integrity": "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" ], "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@csstools/css-color-parser": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", - "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "node_modules/@bruits/satteri-linux-arm64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.10.5.tgz", + "integrity": "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" ], "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.1.0", - "@csstools/css-calc": "^3.3.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "node_modules/@bruits/satteri-linux-x64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.10.5.tgz", + "integrity": "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" ], "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", - "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "node_modules/@bruits/satteri-linux-x64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.10.5.tgz", + "integrity": "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==", + "cpu": [ + "x64" ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "libc": [ + "musl" ], "license": "MIT", - "engines": { - "node": ">=20.19.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, + "node_modules/@bruits/satteri-wasm32-wasi": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.10.5.tgz", + "integrity": "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==", + "cpu": [ + "wasm32" + ], "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.2.3" + }, + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, + "node_modules/@bruits/satteri-win32-arm64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.10.5.tgz", + "integrity": "sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==", + "cpu": [ + "arm64" + ], "license": "MIT", "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } + "os": [ + "win32" + ] }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, + "node_modules/@bruits/satteri-win32-x64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.10.5.tgz", + "integrity": "sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==", + "cpu": [ + "x64" + ], "license": "MIT", "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } + "os": [ + "win32" + ] }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, + "node_modules/@capsizecss/unpack": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.1.tgz", + "integrity": "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==", "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "fontkitten": "^1.0.3" }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=18" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, + "node_modules/@clack/core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.3.tgz", + "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", - "dev": true, - "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">= 20.12.0" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", - "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", - "dev": true, - "license": "Apache-2.0", + "node_modules/@clack/prompts": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.7.0.tgz", + "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==", + "license": "MIT", "dependencies": { - "@eslint/core": "^1.2.1" + "@clack/core": "1.4.3", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">= 20.12.0" } }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "node_modules/@emmetio/abbreviation": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz", + "integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "@emmetio/scanner": "^1.0.4" } }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "node_modules/@emmetio/css-abbreviation": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz", + "integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==", "dev": true, "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", - "dev": true, - "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "@emmetio/scanner": "^1.0.4" } }, - "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "node_modules/@emmetio/css-parser": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@emmetio/css-parser/-/css-parser-0.4.1.tgz", + "integrity": "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==", "dev": true, "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" + "@emmetio/stream-reader": "^2.2.0", + "@emmetio/stream-reader-utils": "^0.1.0" } }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "node_modules/@emmetio/html-matcher": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@emmetio/html-matcher/-/html-matcher-1.3.0.tgz", + "integrity": "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==", "dev": true, - "license": "Apache-2.0", + "license": "ISC", "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" + "@emmetio/scanner": "^1.0.0" } }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "node_modules/@emmetio/scanner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz", + "integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } + "license": "MIT" }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@emmetio/stream-reader": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@emmetio/stream-reader/-/stream-reader-2.2.0.tgz", + "integrity": "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } + "license": "MIT" }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "node_modules/@emmetio/stream-reader-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@emmetio/stream-reader-utils/-/stream-reader-utils-0.1.0.tgz", + "integrity": "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } + "license": "MIT" }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "license": "MIT", + "optional": true, "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "license": "MIT", + "optional": true, "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" + "tslib": "^2.4.0" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "license": "MIT", + "optional": true, "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "tslib": "^2.4.0" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], "license": "MIT", "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "os": [ + "aix" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", - "dev": true, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@playwright/test": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", - "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.62.0" - }, - "bin": { - "playwright": "cli.js" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=20" + "node": ">=18" } }, - "node_modules/@react-router/dev": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-8.3.0.tgz", - "integrity": "sha512-XR+N2fEFOPjczYo2efc3/AOtosbSICCroLF/IxnZ6ErGBeBGRG6SqAso0SYoff0e18OA05qOyhJHFhXKMGIPRw==", - "dev": true, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "@babel/core": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/plugin-syntax-jsx": "^7.29.7", - "@babel/preset-typescript": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@react-router/node": "8.3.0", - "@remix-run/node-fetch-server": "^0.13.3", - "babel-dead-code-elimination": "^1.0.12", - "chokidar": "^5.0.0", - "dedent": "^1.7.2", - "es-module-lexer": "^2.1.0", - "exit-hook": "5.1.0", - "isbot": "^5.1.40", - "jsesc": "3.1.0", - "lodash": "^4.18.1", - "p-map": "^7.0.4", - "pathe": "^2.0.3", - "picocolors": "^1.1.1", - "pkg-types": "^2.3.1", - "prettier": "^3.8.3", - "react-refresh": "^0.18.0", - "semver": "^7.8.1", - "tinyglobby": "^0.2.16", - "valibot": "^1.4.1" - }, - "bin": { - "react-router": "bin.cjs" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=22.22.0" - }, - "peerDependencies": { - "@react-router/serve": "^8.3.0", - "@vitejs/plugin-rsc": "~0.5.26", - "react-router": "^8.3.0", - "react-server-dom-webpack": "^19.2.7", - "typescript": "^5.1.0 || ^6.0.0 || ^7.0.0", - "vite": "^7.0.0 || ^8.0.0", - "wrangler": "^4.0.0" - }, - "peerDependenciesMeta": { - "@react-router/serve": { - "optional": true - }, - "@vitejs/plugin-rsc": { - "optional": true - }, - "react-server-dom-webpack": { - "optional": true - }, - "typescript": { - "optional": true - }, - "wrangler": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@react-router/node": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@react-router/node/-/node-8.3.0.tgz", - "integrity": "sha512-qw5ibcolE1OcwngiEw6t7LR11Hi6yWEDvj6cfvaYROX+w18JPxc0YSmsdn3Wlc9TOX+Qo8FVcxbXRdc9vojn2w==", - "dev": true, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "@remix-run/node-fetch-server": "^0.13.3" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=22.22.0" - }, - "peerDependencies": { - "react-router": "8.3.0", - "typescript": "^5.1.0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@remix-run/node-fetch-server": { - "version": "0.13.3", - "resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.13.3.tgz", - "integrity": "sha512-UfjOXed/DQteaM5VyTfqTeGpHwyL2J5aoRGY6cydip4tt1ehNNeSwuXCC7AEGE0RWBs/7bgKxYkL/B/+UDe4AA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ - "arm64" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ - "x64" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "freebsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], - "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" + "ia32" ], "license": "MIT", "optional": true, @@ -1324,19 +1349,15 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" + "loong64" ], "license": "MIT", "optional": true, @@ -1344,19 +1365,15 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" + "mips64el" ], "license": "MIT", "optional": true, @@ -1364,19 +1381,15 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" + "ppc64" ], "license": "MIT", "optional": true, @@ -1384,19 +1397,15 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" + "riscv64" ], "license": "MIT", "optional": true, @@ -1404,2831 +1413,2877 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ - "arm64" + "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "openharmony" + "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ - "wasm32" + "x64" ], - "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "netbsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "netbsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tailwindcss/node": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", - "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.24.1", - "jiti": "^2.7.0", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.3" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", - "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-x64": "4.3.3", - "@tailwindcss/oxide-freebsd-x64": "4.3.3", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-x64-musl": "4.3.3", - "@tailwindcss/oxide-wasm32-wasi": "4.3.3", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + "node": ">=18" } }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", - "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "openbsd" ], "engines": { - "node": ">= 20" + "node": ">=18" } }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", - "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ - "arm64" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "openbsd" ], "engines": { - "node": ">= 20" + "node": ">=18" } }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", - "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ - "x64" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "openharmony" ], "engines": { - "node": ">= 20" + "node": ">=18" } }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", - "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "freebsd" + "sunos" ], "engines": { - "node": ">= 20" + "node": ">=18" } }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", - "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ - "arm" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">= 20" + "node": ">=18" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", - "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" + "ia32" ], "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">= 20" + "node": ">=18" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", - "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" + "x64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">= 20" + "node": ">=18" } }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", - "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", - "cpu": [ - "x64" - ], + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, "engines": { - "node": ">= 20" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", - "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", - "cpu": [ - "x64" - ], + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", "engines": { - "node": ">= 20" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", - "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.11.1", - "@emnapi/runtime": "^1.11.1", - "@emnapi/wasi-threads": "^1.2.2", - "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.2", - "tslib": "^2.8.1" - }, "engines": { - "node": ">=14.0.0" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, + "license": "Apache-2.0", "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, + "license": "Apache-2.0", "dependencies": { - "tslib": "^2.4.0" + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, + "license": "Apache-2.0", "dependencies": { - "tslib": "^2.4.0" + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, - "inBundle": true, "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "url": "https://eslint.org/donate" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.2", + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", - "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", - "cpu": [ - "arm64" - ], + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { - "node": ">= 20" + "node": ">=18.18.0" } }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", - "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", - "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@tailwindcss/node": "4.3.3", - "@tailwindcss/oxide": "4.3.3", - "tailwindcss": "4.3.3" + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" + "engines": { + "node": ">=18.18.0" } }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=18.18.0" } }, - "node_modules/@testing-library/jest-dom": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", - "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, + "license": "Apache-2.0", "engines": { - "node": ">=22", - "npm": ">=6", - "yarn": ">=1" + "node": ">=12.22" }, - "peerDependencies": { - "@testing-library/dom": ">=10 <11" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, + "license": "Apache-2.0", "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "node": ">=18.18" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, + "node_modules/@iconify-json/lucide": { + "version": "1.2.125", + "resolved": "https://registry.npmjs.org/@iconify-json/lucide/-/lucide-1.2.125.tgz", + "integrity": "sha512-tOCk1QKMtKnCfPAgZRHgjRkQTP7wF5IO+iPKvvp8vxGZYPkSLhx4HTV3Ng0pIZ3wNWrS6kVpHkunJ1dc19L1og==", + "license": "ISC", "dependencies": { - "tslib": "^2.4.0" + "@iconify/types": "*" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", "license": "MIT" }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, + "node_modules/@iconify/utils": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-2.3.0.tgz", + "integrity": "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==", "license": "MIT", "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "@antfu/install-pkg": "^1.0.0", + "@antfu/utils": "^8.1.0", + "@iconify/types": "^2.0.0", + "debug": "^4.4.0", + "globals": "^15.14.0", + "kolorist": "^1.8.0", + "local-pkg": "^1.0.0", + "mlly": "^1.7.4" } }, - "node_modules/@types/debug": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", - "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", - "dev": true, + "node_modules/@iconify/utils/node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", "license": "MIT", "dependencies": { - "@types/ms": "*" + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/hast": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", - "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", - "dev": true, + "node_modules/@iconify/utils/node_modules/@antfu/utils": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-8.1.1.tgz", + "integrity": "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==", "license": "MIT", - "dependencies": { - "@types/unist": "*" + "funding": { + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, + "node_modules/@iconify/utils/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "license": "MIT" }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dev": true, + "node_modules/@iconify/utils/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", "license": "MIT", - "dependencies": { - "@types/unist": "*" + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "26.1.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", - "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", - "dev": true, + "node_modules/@iconify/utils/node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, + "node_modules/@iconify/utils/node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", "license": "MIT", "dependencies": { - "csstype": "^3.2.2" + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" } }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" + "optional": true, + "engines": { + "node": ">=18" } }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", - "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/type-utils": "8.65.0", - "@typescript-eslint/utils": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=20.9.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/libvips" }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.65.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", - "dev": true, - "license": "MIT", + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 4" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", - "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", - "dev": true, - "license": "MIT", + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], "dependencies": { - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", - "debug": "^4.4.3" + "@img/sharp-wasm32": "0.35.3" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=20.9.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", - "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.65.0", - "@typescript-eslint/types": "^8.65.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", - "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", - "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", - "dev": true, - "license": "MIT", + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=20.9.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/libvips" }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", - "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/utils": "8.65.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=20.9.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/libvips" }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", - "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", - "dev": true, - "license": "MIT", + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=20.9.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", - "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.65.0", - "@typescript-eslint/tsconfig-utils": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=20.9.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/libvips" }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", - "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0" - }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=20.9.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/libvips" }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", - "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.65.0", - "eslint-visitor-keys": "^5.0.0" - }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=20.9.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", - "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", - "dev": true, - "license": "ISC" - }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", - "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "^1.0.1" - }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.9.0" }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" + "funding": { + "url": "https://opencollective.com/libvips" }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, - "node_modules/@vitest/coverage-v8": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", - "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.10", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://opencollective.com/libvips" }, - "peerDependencies": { - "@vitest/browser": "4.1.10", - "vitest": "4.1.10" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, - "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", - "dev": true, - "license": "MIT", + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", - "dev": true, - "license": "MIT", + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, "dependencies": { - "@vitest/spy": "4.1.10", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "@img/sharp-wasm32": "0.35.3" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=20.9.0" }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.10", - "pathe": "^2.0.3" + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=12" } }, - "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", - "dev": true, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", - "dev": true, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, "engines": { - "node": ">=0.4.0" + "node": ">=6.0.0" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", "license": "MIT", + "optional": true, "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", "dev": true, + "license": "MIT" + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", "license": "MIT", - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, "license": "MIT", + "optional": true, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=14" } }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" + "playwright": "1.62.1" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" + "bin": { + "playwright": "cli.js" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=20" } }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/ast-types-flow": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", - "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/ast-v8-to-istanbul": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", - "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", - "dev": true, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "cpu": [ + "arm" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/axe-core": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", - "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", - "dev": true, - "license": "MPL-2.0", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=4" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, - "license": "Apache-2.0", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/babel-dead-code-elimination": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", - "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", - "dev": true, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@babel/core": "^7.23.7", - "@babel/parser": "^7.23.6", - "@babel/traverse": "^7.23.7", - "@babel/types": "^7.23.6" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "dev": true, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "18 || 20 || >=22" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.43", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", - "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">=6.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", - "dev": true, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "20 || >=22" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/browserslist": { - "version": "4.28.6", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", - "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@shikijs/core": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", + "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001803", - "electron-to-chromium": "^1.5.389", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" + "@shikijs/primitive": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.5" }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": ">=20" } }, - "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "dev": true, + "node_modules/@shikijs/engine-javascript": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", + "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=20" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", + "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2" }, "engines": { - "node": ">= 0.4" + "node": ">=20" } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, + "node_modules/@shikijs/langs": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", + "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" + "@shikijs/types": "4.4.3" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=20" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001805", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", - "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "dev": true, + "node_modules/@shikijs/primitive": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", + "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" } }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, + "node_modules/@shikijs/themes": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", + "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, "engines": { - "node": ">=18" + "node": ">=20" } }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "dev": true, + "node_modules/@shikijs/types": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" } }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" } }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", - "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "license": "Apache-2.0", - "dependencies": { - "clsx": "^2.1.1" + "node": ">= 20" }, - "funding": { - "url": "https://polar.sh/cva" + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6" + "node": ">= 20" } }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "dev": true, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/confbox": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", - "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie-es": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", - "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 8" + "node": ">= 20" } }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + "node": ">= 20" } }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/damerau-levenshtein": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">= 20" } }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 20" } }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" + "node": ">= 20" } }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 20" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">= 20" } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", - "dev": true, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], "license": "MIT", + "optional": true, "dependencies": { - "character-entities": "^2.0.0" + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "engines": { + "node": ">=14.0.0" } }, - "node_modules/dedent": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", - "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", - "dev": true, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 20" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "license": "MIT", "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "license": "MIT", - "engines": { - "node": ">=6" + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dev": true, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", "license": "MIT", "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "@types/ms": "*" } }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" + "@types/estree": "*" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.389", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", - "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", - "dev": true, - "license": "ISC" + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT" }, - "node_modules/enhanced-resolve": { - "version": "5.24.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", - "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", - "dev": true, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" + "@types/unist": "*" } }, - "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" } }, - "node_modules/es-abstract": { - "version": "1.24.2", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", - "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "dev": true, "license": "MIT", "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "undici-types": "~8.3.0" } }, - "node_modules/es-abstract-get": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", - "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.2", - "is-callable": "^1.2.7", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "@types/node": "*" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", "dev": true, "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">= 4" } }, - "node_modules/es-module-lexer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", - "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0" + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/es-to-primitive": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", - "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", "dev": true, "license": "MIT", - "dependencies": { - "es-abstract-get": "^1.0.0", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "is-callable": "^1.2.7", - "is-date-object": "^1.1.0", - "is-symbol": "^1.1.1" - }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": ">=6" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/eslint": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", - "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", "dev": true, "license": "MIT", - "workspaces": [ - "packages/*" - ], "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.7.0", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://eslint.org/donate" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "jiti": "*" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "engines": { + "node": ">=10" } }, - "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", - "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", "dev": true, "license": "MIT", "dependencies": { - "aria-query": "^5.3.2", - "array-includes": "^3.1.8", - "array.prototype.flatmap": "^1.3.2", - "ast-types-flow": "^0.0.8", - "axe-core": "^4.10.0", - "axobject-query": "^4.1.0", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "hasown": "^2.0.2", - "jsx-ast-utils": "^3.3.5", - "language-tags": "^1.0.9", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "safe-regex-test": "^1.0.3", - "string.prototype.includes": "^2.0.1" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" }, "engines": { - "node": ">=4.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/eslint-plugin-jsx-a11y/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" }, - "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", + "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { - "node": "*" + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" } }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", - "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", - "dev": true, + "node_modules/@vitejs/plugin-vue-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue-jsx/-/plugin-vue-jsx-5.1.6.tgz", + "integrity": "sha512-YXvi4as2clxt6DFw5+a0tTA97ntiQXm/raR8ofNj3aNwwdlVGTiG2gp7EvfZW17P50acL/9bP0ccF4XnqNmlgA==", "license": "MIT", "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" + "@babel/core": "^7.29.0", + "@babel/plugin-syntax-typescript": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7", + "@rolldown/pluginutils": "^1.0.1", + "@vue/babel-plugin-jsx": "^2.0.1" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.0.0" } }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", - "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", - "peerDependencies": { - "eslint": "^9 || ^10" - } - }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", - "dev": true, - "license": "BSD-2-Clause", "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://opencollective.com/vitest" } }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "tinyrainbow": "^3.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://opencollective.com/vitest" } }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "estraverse": "^5.1.0" + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" }, - "engines": { - "node": ">=0.10" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "estraverse": "^5.2.0" + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, - "engines": { - "node": ">=4.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0" + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/@volar/kit": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz", + "integrity": "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" + "license": "MIT", + "dependencies": { + "@volar/language-service": "2.4.28", + "@volar/typescript": "2.4.28", + "typesafe-path": "^0.2.2", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "typescript": "*" } }, - "node_modules/exit-hook": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-5.1.0.tgz", - "integrity": "sha512-INjr2xyxHo7bhAqf5ong++GZPPnpcuBcaXUKt03yf7Fie9yWD7FapL4teOU0+awQazGs5ucBh7xWs/AD+6nhog==", + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "@volar/source-map": "2.4.28" } }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "node_modules/@volar/language-server": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.28.tgz", + "integrity": "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@volar/language-service": "2.4.28", + "@volar/typescript": "2.4.28", + "path-browserify": "^1.0.1", + "request-light": "^0.7.0", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-protocol": "^3.17.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" } }, - "node_modules/exsolve": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", - "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", + "node_modules/@volar/language-service": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.28.tgz", + "integrity": "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "vscode-languageserver-protocol": "^3.17.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", "dev": true, "license": "MIT" }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "node_modules/@vscode/emmet-helper": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz", + "integrity": "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "emmet": "^2.4.3", + "jsonc-parser": "^2.3.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "^3.15.1", + "vscode-uri": "^3.0.8" + } }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "node_modules/@vscode/emmet-helper/node_modules/jsonc-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz", + "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", "dev": true, "license": "MIT" }, - "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "node_modules/@vscode/l10n": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" + "license": "MIT" }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "node_modules/@vue/babel-helper-vue-transform-on": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-2.0.1.tgz", + "integrity": "sha512-uZ66EaFbnnZSYqYEyplWvn46GhZ1KuYSThdT68p+am7MgBNbQ3hphTL9L+xSIsWkdktwhPYLwPgVWqo96jDdRA==", + "license": "MIT" + }, + "node_modules/@vue/babel-plugin-jsx": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@vue/babel-plugin-jsx/-/babel-plugin-jsx-2.0.1.tgz", + "integrity": "sha512-a8CaLQjD/s4PVdhrLD/zT574ZNPnZBOY+IhdtKWRB4HRZ0I2tXBi5ne7d9eCfaYwp5gU5+4KIyFTV1W1YL9xZA==", "license": "MIT", - "engines": { - "node": ">=12.0.0" + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@vue/babel-helper-vue-transform-on": "2.0.1", + "@vue/babel-plugin-resolve-type": "2.0.1", + "@vue/shared": "^3.5.22" }, "peerDependencies": { - "picomatch": "^3 || ^4" + "@babel/core": "^7.0.0-0" }, "peerDependenciesMeta": { - "picomatch": { + "@babel/core": { "optional": true } } }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, + "node_modules/@vue/babel-plugin-resolve-type": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-2.0.1.tgz", + "integrity": "sha512-ybwgIuRGRRBhOU37GImDoWQoz+TlSqap65qVI6iwg/J7FfLTLmMf97TS7xQH9I7Qtr/gp161kYVdhr1ZMraSYQ==", "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" + "@babel/code-frame": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/parser": "^7.28.4", + "@vue/compiler-sfc": "^3.5.22" }, - "engines": { - "node": ">=16.0.0" + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" } }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" } }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", "license": "MIT", "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" } }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, + "node_modules/@vue/devtools-core": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-core/-/devtools-core-8.2.1.tgz", + "integrity": "sha512-s/VfAY9oDTb/kFEWmy461jaFde2MIV1RO/gi1vwM+PAZBZ/Pc2Ndu3BNBdZUze8QDUuyYvElbEEGA83syjJfzA==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "dependencies": { + "@vue/devtools-kit": "^8.2.1", + "@vue/devtools-shared": "^8.2.1" + }, + "peerDependencies": { + "vue": "^3.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, + "node_modules/@vue/devtools-kit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", + "integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==", "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "dependencies": { + "@vue/devtools-shared": "^8.2.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" } }, - "node_modules/function.prototype.name": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", - "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", - "dev": true, + "node_modules/@vue/devtools-shared": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", + "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2", - "hasown": "^2.0.4", - "is-callable": "^1.2.7", - "is-document.all": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "@vue/shared": "3.5.41" } }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" } }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", "license": "MIT", - "engines": { - "node": ">=6.9.0" + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.11.tgz", + "integrity": "sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^3.0.0" }, - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "bin": { + "acorn": "bin/acorn" }, "engines": { - "node": ">= 0.4" + "node": ">=0.4.0" } }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", "dev": true, - "license": "ISC", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-i18n": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ajv-i18n/-/ajv-i18n-4.2.0.tgz", + "integrity": "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.0-beta.0" + } + }, + "node_modules/am-i-vibing": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz", + "integrity": "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.3" + "process-ancestry": "^0.1.0" }, - "engines": { - "node": ">=10.13.0" + "bin": { + "am-i-vibing": "dist/cli.mjs" } }, - "node_modules/globals": { - "version": "17.8.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", - "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" + "color-convert": "^2.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", + "node_modules/ansis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", + "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==", + "license": "ISC", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=14" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=8.6" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/has-property-descriptors": { + "node_modules/array-buffer-byte-length": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0" + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.0" + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -4237,12 +4292,18 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, "engines": { "node": ">= 0.4" }, @@ -4250,14 +4311,17 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, "license": "MIT", "dependencies": { - "has-symbols": "^1.0.3" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -4266,368 +4330,433 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" }, "engines": { "node": ">= 0.4" - } - }, - "node_modules/hast-util-from-parse5": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", - "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "devlop": "^1.0.0", - "hastscript": "^9.0.0", - "property-information": "^7.0.0", - "vfile": "^6.0.0", - "vfile-location": "^5.0.0", - "web-namespaces": "^2.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hast-util-parse-selector": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", - "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-raw": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", - "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "@ungap/structured-clone": "^1.0.0", - "hast-util-from-parse5": "^8.0.0", - "hast-util-to-parse5": "^8.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "parse5": "^7.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0", - "web-namespaces": "^2.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">=12" } }, - "node_modules/hast-util-raw/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } + "license": "MIT" }, - "node_modules/hast-util-raw/node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" } }, - "node_modules/hast-util-sanitize": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", - "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0", - "@ungap/structured-clone": "^1.0.0", - "unist-util-position": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "@types/estree": "^1.0.0" } }, - "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, + "license": "MIT" + }, + "node_modules/astro": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.2.4.tgz", + "integrity": "sha512-+cuLsBns2wwUHI9a10xZMbjrF91m7+QNwqTVeljTx0B8Lf+8h0LgVGjdVIL2FALDKD2I565lczeeS3BFC+KdZg==", "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "stringify-entities": "^4.0.0", - "zwitch": "^2.0.4" + "@astrojs/compiler-rs": "^0.3.2", + "@astrojs/internal-helpers": "0.10.4", + "@astrojs/markdown-satteri": "0.3.7", + "@astrojs/telemetry": "3.3.3", + "@capsizecss/unpack": "^4.0.0", + "@clack/prompts": "^1.1.0", + "@oslojs/encoding": "^1.1.0", + "am-i-vibing": "^0.4.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "ci-info": "^4.4.0", + "clsx": "^2.1.1", + "common-ancestor-path": "^2.0.0", + "cookie": "^2.0.1", + "devalue": "^5.8.1", + "diff": "^8.0.3", + "dset": "^3.1.4", + "es-module-lexer": "^2.0.0", + "esbuild": "^0.28.0", + "find-process": "^2.1.1", + "flattie": "^1.1.1", + "fontace": "~0.4.1", + "get-tsconfig": "5.0.0-beta.4", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "js-yaml": "^4.3.0", + "jsonc-parser": "^3.3.1", + "magic-string": "^1.0.0", + "magicast": "^0.5.2", + "mrmime": "^2.0.1", + "neotraverse": "^1.0.1", + "obug": "^2.1.1", + "p-limit": "^7.3.0", + "p-queue": "^9.1.0", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.4", + "semver": "^7.7.4", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "svgo": "^4.0.1", + "tinyclip": "^0.1.12", + "tinyexec": "^1.0.4", + "tinyglobby": "^0.2.15", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.5", + "unstorage": "^1.17.5", + "vite": "^8.0.13", + "vitefu": "^1.1.2", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^22.0.0", + "zod": "^4.3.6" + }, + "bin": { + "astro": "bin/astro.mjs" + }, + "engines": { + "node": ">=22.12.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.34.0 || ^0.35.0" + }, + "peerDependencies": { + "@astrojs/markdown-remark": "7.2.4" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } } }, - "node_modules/hast-util-to-parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", - "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "node_modules/astro-eslint-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/astro-eslint-parser/-/astro-eslint-parser-3.1.0.tgz", + "integrity": "sha512-lS5qlx401Q1ZUhQ44XQnM4uT2qI6hA0H+vstrd841xGVxunFOs0ut3DWJGYHK1l7mxRxQdDi1j53pfE7/AyGlA==", "dev": true, "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "web-namespaces": "^2.0.0", - "zwitch": "^2.0.0" + "@astrojs/compiler-rs": "^0.4.0", + "@typescript-eslint/scope-manager": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", + "debug": "^4.4.3", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "semver": "^7.8.4", + "tinyglobby": "^0.2.17" + }, + "engines": { + "node": "^22.22.3 || ^24.16.0 || >=26.3.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/ota-meshi" } }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "node_modules/astro-eslint-parser/node_modules/@astrojs/compiler-binding": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.4.0.tgz", + "integrity": "sha512-x2RjDUuWfwLNtc3mjAdSRInwqh/rqbLar9cm/5FOMbHvmYZB7yfKewzSclAxWjIZsypJDXv1lhaP2WG+P8TK3g==", "dev": true, "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" + "engines": { + "node": "^20.19.0 || >=22.12.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hastscript": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", - "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "optionalDependencies": { + "@astrojs/compiler-binding-darwin-arm64": "0.4.0", + "@astrojs/compiler-binding-darwin-x64": "0.4.0", + "@astrojs/compiler-binding-linux-arm64-gnu": "0.4.0", + "@astrojs/compiler-binding-linux-arm64-musl": "0.4.0", + "@astrojs/compiler-binding-linux-x64-gnu": "0.4.0", + "@astrojs/compiler-binding-linux-x64-musl": "0.4.0", + "@astrojs/compiler-binding-wasm32-wasi": "0.4.0", + "@astrojs/compiler-binding-win32-arm64-msvc": "0.4.0", + "@astrojs/compiler-binding-win32-x64-msvc": "0.4.0" + } + }, + "node_modules/astro-eslint-parser/node_modules/@astrojs/compiler-binding-darwin-arm64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.4.0.tgz", + "integrity": "sha512-ZVUwHundaQyFNjE6uoa0usaC0WOCitDCLS/4mdb4rOiJXwVUuKJBMxI5WMzXLWmamsXtK/Z//ifLXvV5Yeh4Hw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^4.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "node_modules/astro-eslint-parser/node_modules/@astrojs/compiler-binding-darwin-x64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.4.0.tgz", + "integrity": "sha512-FI6G8AY8u6fR1SI/QRR5yGMwtvZwP34CDmZpZ5HwJGa50UM1VISTLhqkhV4a476pmgd25X1Aur2dqw6hUnrlKA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "node_modules/astro-eslint-parser/node_modules/@astrojs/compiler-binding-linux-arm64-gnu": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.4.0.tgz", + "integrity": "sha512-lB9gLFJK7m82EnjaU8nlRBEfcwGNeHidW3sSjODTUjMNaoewVuUz9fwwdY5M4jiSXIqWLH3yl6TX8FTDKA74Sw==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "node_modules/astro-eslint-parser/node_modules/@astrojs/compiler-binding-linux-arm64-musl": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.4.0.tgz", + "integrity": "sha512-HPbvWqbxFxyaoQJhLxCaSjtYBx9KBo7JGVzEFZCmMl968a2PsSH0UfiODYgYPXofTOIsIH2aoCcrHXML0IA3ig==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/astro-eslint-parser/node_modules/@astrojs/compiler-binding-linux-x64-gnu": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.4.0.tgz", + "integrity": "sha512-tQKolMxoJ/+0AmLWm1PmJ/i+z3i10ZU1bNuVjEDulCf48azEMtUNjTZgHJ5MPtpYRNc7dlETr8QujUfduzoC7Q==", + "cpu": [ + "x64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 4" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "node_modules/astro-eslint-parser/node_modules/@astrojs/compiler-binding-linux-x64-musl": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.4.0.tgz", + "integrity": "sha512-5v5YymudsxMHp3NBLCS8BUlu5CRqeLtWD9cKS/4nIhIEHCbpz9okmVV6I0HWqmBAPhWYcDa3vw/vltYPrOQCTA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/astro-eslint-parser/node_modules/@astrojs/compiler-binding-wasm32-wasi": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.4.0.tgz", + "integrity": "sha512-m/phuH3x3PREvv1OnkM44NoPh4MatUadix1fB1u5SvMLCyDTUZykDJbKnWf1cjnYmHdlB8HcjTjl6JrCqAIXcw==", + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.2.2" + }, "engines": { - "node": ">=0.8.19" + "node": ">=14.0.0" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "node_modules/astro-eslint-parser/node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.4.0.tgz", + "integrity": "sha512-B9zYf3okEY83kM8gydlpH2BHP00w4ifxPqlYlWrgTwuD6wnkrJDCwBlgy1q31cERjCJRXN1lrE2VmkLvFjv/6g==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=8" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "node_modules/astro-eslint-parser/node_modules/@astrojs/compiler-binding-win32-x64-msvc": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.4.0.tgz", + "integrity": "sha512-zB0Nrv0dGc0zZWPGDRmmETTPhDRqyZjAjk+gWMlVrJX5U89obpB3VUUE1ZiHxOCN5LQojeLK6O8L/dnoHolvNQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "node_modules/astro-eslint-parser/node_modules/@astrojs/compiler-rs": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.4.0.tgz", + "integrity": "sha512-koVikeon1kreEy+/JzLQRy3vzHHQVOjycs4degg4vFufKApZOwMZvSSAEztYNhmcQVfNVsVZZI4cEge3cexAbQ==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" + "@astrojs/compiler-binding": "0.4.0" }, "engines": { - "node": ">= 0.4" + "node": ">=22.12.0" } }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "node_modules/astro-eslint-parser/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=10" } }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, + "node_modules/astro/node_modules/magic-string": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.2.tgz", + "integrity": "sha512-veT/+7iXrXzT39XnEN4lOxtNl72dMgJ8Lp+5Bd6YcMSWpb0n0MjBM8Uuooi6jgJr8dhUW2swQgBmoZVMni5SVg==", "license": "MIT", "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/astro/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=10" } }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", "dev": true, "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "possible-typed-array-names": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -4636,108 +4765,157 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", "engines": { "node": ">= 0.4" - }, + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, "engines": { - "node": ">= 0.4" + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz", + "integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "20 || >=22" } }, - "node_modules/is-document.all": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", - "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", - "dev": true, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "call-bound": "^1.0.4" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, - "engines": { - "node": ">= 0.4" + "bin": { + "browserslist": "cli.js" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", "dev": true, "license": "MIT", + "dependencies": { + "@types/node": "*" + }, "engines": { - "node": ">=0.10.0" + "node": ">=4.0" } }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3" + "run-applescript": "^7.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" }, "engines": { "node": ">= 0.4" @@ -4746,25 +4924,30 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, "engines": { "node": ">= 0.4" }, @@ -4772,1814 +4955,5437 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", "license": "MIT", - "engines": { - "node": ">= 0.4" - }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", "license": "MIT", - "engines": { - "node": ">= 0.4" - }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">= 20.19.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://paulmillr.com/funding/" } }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "which-typed-array": "^1.1.16" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=20" } }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "node_modules/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "node_modules/cliui/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/isbot": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.0.tgz", - "integrity": "sha512-gbZiGCb4B5xaoxg9mS7koAyRdvJnArk10VLSHOgz6rtBG93/pi1xOFaVvXMKZ7JXgyZ8zAbNRK5uIBdIUTFSqw==", - "dev": true, - "license": "Unlicense", + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=6" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" + "color-name": "~1.1.4" }, "engines": { - "node": ">=10" + "node": ">=7.0.0" } }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsdom": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", - "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^5.1.11", - "@asamuzakjp/dom-selector": "^7.1.1", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", - "@exodus/bytes": "^1.15.0", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.3.5", - "parse5": "^8.0.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.25.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", - "xml-name-validator": "^5.0.0" - }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "node": ">=20" } }, - "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, + "node_modules/common-ancestor-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", "license": "BlueOak-1.0.0", "engines": { - "node": "20 || >=22" + "node": ">= 18" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", "license": "MIT" }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true, "license": "MIT" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": ">=6" + "node": ">= 8" } }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", - "dev": true, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", "license": "MIT", "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - }, - "engines": { - "node": ">=4.0" + "uncrypto": "^0.1.3" } }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "dev": true, - "license": "(MIT OR GPL-3.0-or-later)", + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/language-subtag-registry": { - "version": "0.3.23", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", - "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", - "dev": true, - "license": "CC0-1.0" + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } }, - "node_modules/language-tags": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", - "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true, "license": "MIT", - "dependencies": { - "language-subtag-registry": "^0.3.20" + "bin": { + "cssesc": "bin/cssesc" }, "engines": { - "node": ">=0.10" + "node": ">=4" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", "license": "MIT", "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "css-tree": "~2.2.0" }, "engines": { - "node": ">= 0.8.0" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" } }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "dev": true, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", "license": "MIT", "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" }, "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/inspect-js" } }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, "engines": { - "node": ">= 12.0.0" + "node": ">=6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT" + }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, "engines": { - "node": ">= 12.0.0" + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", "engines": { - "node": ">= 12.0.0" + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", "engines": { - "node": ">= 12.0.0" + "node": ">=12" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", "engines": { - "node": ">= 12.0.0" + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.1.tgz", + "integrity": "sha512-+17vil3EVQRzvtDJSFuTWEb8XJRvXqAiV3qZyQWD398QeXUa6CxsUyMdD1fxzEhUrd4FojitFz7lhIHBTlV4fw==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "license": "MIT", "dependencies": { - "p-locate": "^5.0.0" + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", "engines": { - "node": ">=10" + "node": ">=0.12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "dev": true, - "license": "MIT", + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/fb55/domhandler?sponsor=1" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", "dependencies": { - "yallist": "^3.0.2" + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/lucide-react": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz", - "integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" } }, - "node_modules/magicast": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", - "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "node_modules/editorconfig/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.3", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" + "balanced-match": "^1.0.0" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", "dev": true, "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", "dependencies": { - "semver": "^7.5.3" + "brace-expansion": "^2.0.2" }, "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.17" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/markdown-table": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", - "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "node_modules/editorconfig/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/electron-to-chromium": { + "version": "1.5.412", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz", + "integrity": "sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==", + "license": "ISC" + }, + "node_modules/emmet": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz", + "integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "workspaces": [ + "./packages/scanner", + "./packages/abbreviation", + "./packages/css-abbreviation", + "./" + ], + "dependencies": { + "@emmetio/abbreviation": "^2.3.3", + "@emmetio/css-abbreviation": "^2.1.8" } }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "license": "MIT", "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">=10.13.0" } }, - "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "dev": true, - "license": "MIT", + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", "engines": { - "node": ">=12" + "node": ">=0.12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", - "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", - "dev": true, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/mdast-util-gfm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", - "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, "license": "MIT", "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", - "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", "dev": true, "license": "MIT", "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mdast-util-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">= 0.4" } }, - "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">= 0.4" } }, - "node_modules/mdast-util-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", - "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" + "es-errors": "^1.3.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">= 0.4" } }, - "node_modules/mdast-util-gfm-task-list-item": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", - "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, "license": "MIT", "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">= 0.4" } }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "license": "MIT", "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" + "hasown": "^2.0.2" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">= 0.4" } }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", "dev": true, "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", - "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", - "dev": true, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" + "bin": { + "esbuild": "bin/esbuild" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, - "node_modules/mdast-util-to-string": { + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0" + "engines": { + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "node_modules/eslint": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.0.tgz", + "integrity": "sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "node_modules/eslint-plugin-astro": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-astro/-/eslint-plugin-astro-3.1.0.tgz", + "integrity": "sha512-I+v3DNIVCPPQC91jZIevvp5eTdzOhZYQSL43PnmbHBcuaiLhusN7czT8AK2ISH53+sO3p8rzqiR81j7VJ2qDeg==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], "license": "MIT", "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "@eslint-community/eslint-utils": "^4.5.1", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@typescript-eslint/types": "^8.61.0", + "astro-eslint-parser": "^3.0.0", + "espree": "^11.0.0", + "globals": "^17.0.0", + "postcss": "^8.5.3", + "postcss-selector-parser": "^7.1.0" + }, + "engines": { + "node": "^22.22.3 || ^24.16.0 || >=26.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "@typescript-eslint/parser": ">=8.61.0", + "eslint": ">=10.0.0", + "eslint-plugin-jsx-a11y": ">=6.10.2", + "typescript-eslint": ">=8.61.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/parser": { + "optional": true + }, + "eslint-plugin-jsx-a11y": { + "optional": true + }, + "typescript-eslint": { + "optional": true + } } }, - "node_modules/micromark-extension-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", - "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", "dev": true, "license": "MIT", "dependencies": { - "micromark-extension-gfm-autolink-literal": "^2.0.0", - "micromark-extension-gfm-footnote": "^2.0.0", - "micromark-extension-gfm-strikethrough": "^2.0.0", - "micromark-extension-gfm-table": "^2.0.0", - "micromark-extension-gfm-tagfilter": "^2.0.0", - "micromark-extension-gfm-task-list-item": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, - "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", - "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "node_modules/eslint-plugin-jsx-a11y/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/micromark-extension-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "brace-expansion": "^1.1.7" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": "*" } }, - "node_modules/micromark-extension-gfm-strikethrough": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", - "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "node_modules/eslint-plugin-vue": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.10.0.tgz", + "integrity": "sha512-dL9x9rBHqqNcByWiLOHK6L0SB97V82/NC0cZRn9cXPjM7pCuWlpQQP9bFH4vjBv80ej1ZpzAkuD8zWH1o9bZbA==", "dev": true, "license": "MIT", "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "@eslint-community/eslint-utils": "^4.9.1", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^7.1.4", + "semver": "^7.8.5", + "xml-name-validator": "^5.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "vue-eslint-parser": "^10.3.0" + }, + "peerDependenciesMeta": { + "@stylistic/eslint-plugin": { + "optional": true + }, + "@typescript-eslint/parser": { + "optional": true + } } }, - "node_modules/micromark-extension-gfm-table": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", - "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "node_modules/eslint-plugin-vue/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-plugin-vuejs-accessibility": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vuejs-accessibility/-/eslint-plugin-vuejs-accessibility-2.6.0.tgz", + "integrity": "sha512-QtJO3UdLH+aydue/im7aBsYKO99ouJ6hm4wEHL1Xm/BwDfYTAmXEPhTImNwlOHp2bbid7rNY7IrHz+IZzGApYA==", "dev": true, "license": "MIT", "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "aria-query": "^5.3.0", + "vue-eslint-parser": "^9.0.0 || ^10.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "eslint": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "globals": ">= 13.12.1" } }, - "node_modules/micromark-extension-gfm-tagfilter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", - "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "micromark-util-types": "^2.0.0" + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/eslint" } }, - "node_modules/micromark-extension-gfm-task-list-item": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", - "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/eslint" } }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" } }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", "license": "MIT", "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "fast-string-truncated-width": "^3.0.2" } }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", "dev": true, "funding": [ { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" + "type": "github", + "url": "https://github.com/sponsors/fastify" }, { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" + "type": "opencollective", + "url": "https://opencollective.com/fastify" } ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", "license": "MIT", "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" + "fast-string-width": "^3.0.2" } }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], "license": "MIT", "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/micromark-util-character": { + "node_modules/find-process": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "resolved": "https://registry.npmjs.org/find-process/-/find-process-2.1.1.tgz", + "integrity": "sha512-SrQDx3QhlmHM90iqn9rdjCQcw/T+WlpOkHFsjoRgB+zTpDfltNA1VSNYeYELwhUTJy12UFxqjWhmhOrJc+o4sA==", "license": "MIT", "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "chalk": "~4.1.2", + "commander": "^14.0.3", + "loglevel": "^1.9.2" + }, + "bin": { + "find-process": "dist/cjs/bin/find-process.js" } }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], "license": "MIT", "dependencies": { - "micromark-util-symbol": "^2.0.0" + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], "license": "MIT", "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" } }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "license": "ISC" + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", "license": "MIT", "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" + "fontkitten": "^1.0.2" } }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "node_modules/fontkitten": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", + "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", "license": "MIT", "dependencies": { - "micromark-util-symbol": "^2.0.0" + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" } }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], "license": "MIT", "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" ], - "license": "MIT" + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } }, - "node_modules/micromark-util-normalize-identifier": { + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "5.0.0-beta.4", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", + "integrity": "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "engines": { + "node": ">=20.20.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "17.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", + "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/h3": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.3", + "crossws": "^0.3.5", + "defu": "^6.1.6", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/happy-dom": { + "version": "20.11.6", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.6.tgz", + "integrity": "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-4.0.0.tgz", + "integrity": "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports/node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "license": "MIT" + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nanostores": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.2.tgz", + "integrity": "sha512-B0UbxzK1s0CN8Xht6r+7iT5+xV8PTaRERR1nATeplRv1Rw5YLWfVAid0hkqY3EceqpG4RjTk8GAwIxQY39Rnwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neotraverse": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz", + "integrity": "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.5.tgz", + "integrity": "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/open": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.1.tgz", + "integrity": "sha512-NzwMUB6C1D0+Kd+9iMS/H4k+Ck3cTX6Ckyfr/gAGlmvSE1LUQZnEZvWBi4PYmMwH/S5SMeTXnE+9uAz8uF+pWw==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.2.0", + "wsl-utils": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.1.tgz", + "integrity": "sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "9.3.3", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz", + "integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", + "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/powershell-utils": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz", + "integrity": "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/process-ancestry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/process-ancestry/-/process-ancestry-0.1.0.tgz", + "integrity": "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/process-nextick-args": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", "funding": [ { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" + "type": "individual", + "url": "https://github.com/sponsors/antfu" }, { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" + "type": "individual", + "url": "https://github.com/sponsors/sxzz" } ], + "license": "MIT" + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, "license": "MIT", "dependencies": { - "micromark-util-symbol": "^2.0.0" + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" } }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], "license": "MIT", "dependencies": { - "micromark-util-types": "^2.0.0" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], "license": "MIT", "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", "license": "MIT", "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "node_modules/request-light": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.7.0.tgz", + "integrity": "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], "license": "MIT" }, - "node_modules/micromark-util-types": { + "node_modules/require-from-string": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=0.10.0" } }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/rolldown": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" + }, "bin": { - "nanoid": "bin/nanoid.cjs" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", - "dev": true, + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", "license": "MIT", "engines": { "node": ">=18" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" + "isarray": "^2.0.5" }, "engines": { - "node": ">= 0.4" + "node": ">=0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" + "es-errors": "^1.3.0", + "isarray": "^2.0.5" }, "engines": { "node": ">= 0.4" @@ -6588,17 +10394,23 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -6607,851 +10419,797 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], + "node_modules/satteri": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", + "integrity": "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==", "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.5", + "@types/hast": "^3.0.5", + "@types/mdast": "^4.0.4", + "@types/unist": "^3.0.3" + }, + "optionalDependencies": { + "@bruits/satteri-darwin-arm64": "0.10.5", + "@bruits/satteri-darwin-x64": "0.10.5", + "@bruits/satteri-linux-arm64-gnu": "0.10.5", + "@bruits/satteri-linux-arm64-musl": "0.10.5", + "@bruits/satteri-linux-x64-gnu": "0.10.5", + "@bruits/satteri-linux-x64-musl": "0.10.5", + "@bruits/satteri-wasm32-wasi": "0.10.5", + "@bruits/satteri-win32-arm64-msvc": "0.10.5", + "@bruits/satteri-win32-x64-msvc": "0.10.5" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=12.20.0" + "node": ">=11.0.0" } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "license": "MIT", "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 0.4" } }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", "dev": true, "license": "MIT", "dependencies": { - "yocto-queue": "^0.1.0" + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.4" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "dev": true, - "license": "MIT", + "license": "MIT" + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "optional": true, "dependencies": { - "p-limit": "^3.0.2" + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" }, "engines": { - "node": ">=10" + "node": ">=20.9.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/p-map": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz", - "integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=10" } }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true, - "license": "(MIT AND Zlib)" - }, - "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^8.0.0" + "shebang-regex": "^3.0.0" }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" + "engines": { + "node": ">=8" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, + "node_modules/shiki": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", + "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.3", + "@shikijs/engine-javascript": "4.4.3", + "@shikijs/engine-oniguruma": "4.4.3", + "@shikijs/langs": "4.4.3", + "@shikijs/themes": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, "engines": { - "node": ">=8" + "node": ">=20" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { + "node_modules/side-channel": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, "engines": { - "node": ">=12" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/pkg-types": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", - "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { - "confbox": "^0.2.4", - "exsolve": "^1.0.8", - "pathe": "^2.0.3" - } - }, - "node_modules/playwright": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", - "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.62.0" - }, - "bin": { - "playwright": "cli.js" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" }, "engines": { - "node": ">=20" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", - "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" + "node": ">= 0.4" }, - "engines": { - "node": ">=20" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss": { - "version": "8.5.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" }, - "node_modules/prettier": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", - "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, + "license": "ISC", "engines": { "node": ">=14" }, "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=18" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, - "node_modules/property-information": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", - "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "license": "BSD-3-Clause", "engines": { - "node": ">=6" + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" } }, - "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", - "license": "MIT", + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.7" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "license": "MIT" }, - "node_modules/react-router": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz", - "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==", + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, "license": "MIT", "dependencies": { - "cookie-es": "^3.1.1" + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" }, "engines": { - "node": ">=22.22.0" - }, - "peerDependencies": { - "react": ">=19.2.7", - "react-dom": ">=19.2.7" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } + "node": ">= 0.4" } }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "safe-buffer": "~5.1.0" } }, - "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, "engines": { - "node": ">= 20.19.0" + "node": ">=12" }, "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { "node": ">=8" } }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/rehype-raw": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", - "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", "dev": true, "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-raw": "^9.0.0", - "vfile": "^6.0.0" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">= 0.4" } }, - "node_modules/rehype-sanitize": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", - "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", "dev": true, "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-sanitize": "^5.0.0" + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/rehype-stringify": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", - "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", "dev": true, "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-to-html": "^9.0.0", - "unified": "^11.0.0" + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/remark-gfm": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", - "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dev": true, "license": "MIT", "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-gfm": "^3.0.0", - "micromark-extension-gfm": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/remark-rehype": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", - "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "mdast-util-to-hast": "^13.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", - "unified": "^11.0.0" + "ansi-regex": "^5.0.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">=8" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/reveal.js": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/reveal.js/-/reveal.js-6.0.1.tgz", - "integrity": "sha512-9eacArNIgqO2HGWOK+93gJNn+gvdGDVbSq+i2u3Ja9kjiHps0XNLpgYTZTYjKRH91uXy3clGimeGiw4umHG/tg==", - "dev": true, - "license": "MIT" - }, - "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", - "dev": true, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" + "has-flag": "^4.0.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" + "node": ">=8" } }, - "node_modules/safe-array-concat": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", - "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", - "dev": true, + "node_modules/svgo": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.2.tgz", + "integrity": "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "get-intrinsic": "^1.3.0", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" }, "engines": { - "node": ">=0.4" + "node": ">=16" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/svgo" } }, - "node_modules/safe-array-concat/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" + "node_modules/svgo/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "license": "MIT" }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, "engines": { - "node": ">= 0.4" + "node": ">=6" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/safe-push-apply/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, + "license": "MIT" + }, + "node_modules/tinyclip": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz", + "integrity": "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==", "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^16.14.0 || >= 17.3.0" } }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "license": "MIT", "engines": { - "node": ">=v12.22.7" + "node": ">=18" } }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=10" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, "engines": { - "node": ">= 0.4" + "node": ">=14.0.0" } }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, "engines": { - "node": ">= 0.4" + "node": ">=6" } }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "dev": true, - "license": "MIT" + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, "engines": { - "node": ">=8" + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, "engines": { - "node": ">=8" + "node": ">= 0.8.0" } }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "dev": true, "license": "MIT", "dependencies": { + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" + "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -7460,17 +11218,20 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" }, "engines": { "node": ">= 0.4" @@ -7479,18 +11240,19 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -7499,121 +11261,97 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "node_modules/typesafe-path": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz", + "integrity": "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==", "dev": true, "license": "MIT" }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">= 0.4" + "node": ">=14.17" } }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "node_modules/typescript-auto-import-cache": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz", + "integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==", "dev": true, "license": "MIT", "dependencies": { - "safe-buffer": "~5.1.0" + "semver": "^7.3.8" } }, - "node_modules/string.prototype.includes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", - "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "node_modules/typescript-auto-import-cache/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">= 0.4" + "node": ">=10" } }, - "node_modules/string.prototype.trim": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", - "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "node_modules/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.2", - "es-object-atoms": "^1.1.2", - "has-property-descriptors": "^1.0.2", - "safe-regex-test": "^1.1.0" + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/string.prototype.trimend": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", - "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.7.0.tgz", + "integrity": "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==", + "license": "MIT" + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.2" + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -7622,709 +11360,940 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=22.19.0" } }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, + "node_modules/unifont": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.5.tgz", + "integrity": "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==", "license": "MIT", "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" + "css-tree": "^3.1.0", + "ohash": "^2.0.11", + "undici": "^8.0.0" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@types/unist": "^3.0.0" }, - "engines": { - "node": ">=8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tailwind-merge": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", - "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/tailwindcss": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", - "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "dev": true, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "@types/unist": "^3.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://opencollective.com/unified" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "dev": true, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", "license": "MIT", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" }, "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, "engines": { - "node": ">=14.0.0" + "node": ">=18.12.0" } }, - "node_modules/tldts": { - "version": "7.4.8", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz", - "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==", - "dev": true, + "node_modules/unplugin-icons": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/unplugin-icons/-/unplugin-icons-0.22.0.tgz", + "integrity": "sha512-CP+iZq5U7doOifer5bcM0jQ9t3Is7EGybIYt3myVxceI8Zuk8EZEpe1NPtJvh7iqMs1VdbK0L41t9+um9VuuLw==", "license": "MIT", "dependencies": { - "tldts-core": "^7.4.8" + "@antfu/install-pkg": "^0.5.0", + "@antfu/utils": "^0.7.10", + "@iconify/utils": "^2.2.0", + "debug": "^4.4.0", + "kolorist": "^1.8.0", + "local-pkg": "^0.5.1", + "unplugin": "^2.1.0" }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.4.8", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz", - "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tough-cookie": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", - "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" + "funding": { + "url": "https://github.com/sponsors/antfu" }, - "engines": { - "node": ">=16" + "peerDependencies": { + "@svgr/core": ">=7.0.0", + "@svgx/core": "^1.0.1", + "@vue/compiler-sfc": "^3.0.2 || ^2.7.0", + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0", + "vue-template-compiler": "^2.6.12", + "vue-template-es2015-compiler": "^1.9.0" + }, + "peerDependenciesMeta": { + "@svgr/core": { + "optional": true + }, + "@svgx/core": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue-template-compiler": { + "optional": true + }, + "vue-template-es2015-compiler": { + "optional": true + } } }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, + "node_modules/unplugin-utils": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz", + "integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==", "license": "MIT", "dependencies": { - "punycode": "^2.3.1" + "pathe": "^2.0.3", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=20" + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "dev": true, + "node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } } }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "node_modules/unstorage/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", - "engines": { - "node": ">=18.12" + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" }, "peerDependencies": { - "typescript": ">=4.8.4" + "browserslist": ">= 4.21.0" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "license": "0BSD", - "optional": true + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true, + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", "license": "MIT", "dependencies": { - "prelude-ls": "^1.2.1" + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" }, - "engines": { - "node": ">= 0.8.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" }, - "engines": { - "node": ">= 0.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "license": "MIT", "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" }, "engines": { - "node": ">= 0.4" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/typed-array-length": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", - "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", - "dev": true, + "node_modules/vite-dev-rpc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/vite-dev-rpc/-/vite-dev-rpc-2.0.0.tgz", + "integrity": "sha512-yKwbTwdHKSD2k/aGqyWpPHepo45OQc8lH3/6IfT4ZqeKE26ooKvi4WIEKzqWav8v+9Is8u1k8q54hvOmqASazA==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.9", - "for-each": "^0.3.5", - "gopd": "^1.2.0", - "is-typed-array": "^1.1.15", - "possible-typed-array-names": "^1.1.0", - "reflect.getprototypeof": "^1.0.10" + "birpc": "^4.0.0", + "vite-hot-client": "^2.2.0" }, - "engines": { - "node": ">= 0.4" + "funding": { + "url": "https://github.com/sponsors/antfu" }, + "peerDependencies": { + "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0 || ^8.0.0" + } + }, + "node_modules/vite-dev-rpc/node_modules/birpc": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-4.2.0.tgz", + "integrity": "sha512-KxgKcZPfrtzJDDALHPguGpGJUrzdgpymyiQQgzFjWreHMOpWrnFNVREr5J48x2DBh8ZVioscrV1SBkDipGiX+Q==", + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "node_modules/vite-hot-client": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/vite-hot-client/-/vite-hot-client-2.2.0.tgz", + "integrity": "sha512-76Zs9zrHbH7M7wqeyooGQKdX+yg0pQ0xuQ1PbFp4z5a0Lzn2e5IPFoCswnmqZ4GiwqB4Jo3WcDAMO9jARTJl8w==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" }, - "engines": { - "node": ">=14.17" + "peerDependencies": { + "vite": "^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0" } }, - "node_modules/typescript-eslint": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", - "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", - "dev": true, + "node_modules/vite-plugin-inspect": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/vite-plugin-inspect/-/vite-plugin-inspect-11.4.1.tgz", + "integrity": "sha512-ShOFe2PURXGvRS5OrgmOLZOCwDTD7dEBVt0tMpFPKb9AsvqXKCRGM8QgKrUbRbJYFXScHvDPpGRd28rYidC0tA==", "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.65.0", - "@typescript-eslint/parser": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/utils": "8.65.0" + "ansis": "^4.3.0", + "error-stack-parser-es": "^1.0.5", + "obug": "^2.1.1", + "ohash": "^2.0.11", + "open": "^11.0.0", + "perfect-debounce": "^2.1.0", + "sirv": "^3.0.2", + "unplugin-utils": "^0.3.1", + "vite-dev-rpc": "^2.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=14" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://github.com/sponsors/antfu" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "vite": "^6.0.0 || ^7.0.0-0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } } }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, + "node_modules/vite-plugin-vue-devtools": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-8.2.1.tgz", + "integrity": "sha512-5JLxXWWCo5lJMw16/xVeNvJ8k2zLwZPf1vITLzya/2IePrCBeGe/p/iAokgXHZpEi39fcYtPXmO8SaKeXmqCAA==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" + "@vue/devtools-core": "^8.2.1", + "@vue/devtools-kit": "^8.2.1", + "@vue/devtools-shared": "^8.2.1", + "sirv": "^3.0.2", + "vite-plugin-inspect": "^11.3.3", + "vite-plugin-vue-inspector": "^6.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=v14.21.3" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", - "dev": true, + "node_modules/vite-plugin-vue-inspector": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/vite-plugin-vue-inspector/-/vite-plugin-vue-inspector-6.0.0.tgz", + "integrity": "sha512-OpyITJLgZNibxlrik1EmRtvXHDjLRxNPsWkGFTERZs2LgMEdG4W0WoFt5GIgp3a3jRou+eJR8U1zOBk/XQgEbw==", "license": "MIT", - "engines": { - "node": ">=20.18.1" + "dependencies": { + "@babel/core": "^7.23.0", + "@babel/plugin-proposal-decorators": "^7.23.0", + "@babel/plugin-syntax-import-attributes": "^7.22.5", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-transform-typescript": "^7.22.15", + "@vue/babel-plugin-jsx": "^1.1.5", + "@vue/compiler-dom": "^3.3.4", + "kolorist": "^1.8.0", + "magic-string": "^0.30.4" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, + "node_modules/vite-plugin-vue-inspector/node_modules/@vue/babel-helper-vue-transform-on": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz", + "integrity": "sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==", "license": "MIT" }, - "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", - "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", - "dev": true, + "node_modules/vite-plugin-vue-inspector/node_modules/@vue/babel-plugin-jsx": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vue/babel-plugin-jsx/-/babel-plugin-jsx-1.5.0.tgz", + "integrity": "sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.2", + "@vue/babel-helper-vue-transform-on": "1.5.0", + "@vue/babel-plugin-resolve-type": "1.5.0", + "@vue/shared": "^3.5.18" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + } + } + }, + "node_modules/vite-plugin-vue-inspector/node_modules/@vue/babel-plugin-resolve-type": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-1.5.0.tgz", + "integrity": "sha512-Wm/60o+53JwJODm4Knz47dxJnLDJ9FnKnGZJbUUf8nQRAtt6P+undLUAVU3Ha33LxOJe6IPoifRQ6F/0RrU31w==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/parser": "^7.28.0", + "@vue/compiler-sfc": "^3.5.18" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, - "peerDependencies": { - "browserslist": ">= 4.21.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/valibot": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", - "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "typescript": ">=5" + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/vfile-location": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", - "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile": "^6.0.0" + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/vite": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", - "dev": true, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { + "vite": { "optional": true } } }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -8352,12 +12321,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -8395,69 +12364,397 @@ }, "jsdom": { "optional": true - }, - "vite": { - "optional": false + }, + "vite": { + "optional": false + } + } + }, + "node_modules/volar-service-css": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.71.tgz", + "integrity": "sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-css-languageservice": "^6.3.0", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-emmet": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.71.tgz", + "integrity": "sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/css-parser": "^0.4.1", + "@emmetio/html-matcher": "^1.3.0", + "@vscode/emmet-helper": "^2.9.3", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-html": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.71.tgz", + "integrity": "sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-html-languageservice": "^5.3.0", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-prettier": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.71.tgz", + "integrity": "sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0", + "prettier": "^2.2 || ^3.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + }, + "prettier": { + "optional": true + } + } + }, + "node_modules/volar-service-typescript": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.71.tgz", + "integrity": "sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-browserify": "^1.0.1", + "semver": "^7.6.2", + "typescript-auto-import-cache": "^0.3.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-nls": "^5.2.0", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-typescript-twoslash-queries": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.71.tgz", + "integrity": "sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-typescript/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/volar-service-yaml": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.71.tgz", + "integrity": "sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-uri": "^3.0.8", + "yaml-language-server": "~1.23.0" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/vscode-css-languageservice": { + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz", + "integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-css-languageservice/node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-html-languageservice": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz", + "integrity": "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-json-languageservice": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-4.1.8.tgz", + "integrity": "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsonc-parser": "^3.0.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "^3.16.0", + "vscode-nls": "^5.0.0", + "vscode-uri": "^3.0.2" + }, + "engines": { + "npm": ">=7.0.0" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.1.tgz", + "integrity": "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.18.2", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.2.tgz", + "integrity": "sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "9.0.1", + "vscode-languageserver-types": "3.18.0" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz", + "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-languageserver/node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver/node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver/node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-nls": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz", + "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true } } }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "node_modules/vue-component-type-helpers": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.11.tgz", + "integrity": "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-eslint-parser": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.1.tgz", + "integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==", "dev": true, "license": "MIT", "dependencies": { - "xml-name-validator": "^5.0.0" + "debug": "^4.4.0", + "eslint-scope": "^8.2.0 || ^9.0.0", + "eslint-visitor-keys": "^4.2.0 || ^5.0.0", + "espree": "^10.3.0 || ^11.0.0", + "esquery": "^1.6.0", + "semver": "^7.6.3" }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/vue-eslint-parser/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", - "dev": true, "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" }, "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", "dev": true, "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=12" } }, "node_modules/which": { @@ -8599,6 +12896,151 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.0.tgz", + "integrity": "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -8609,24 +13051,102 @@ "node": ">=18" } }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", "license": "MIT" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, "license": "ISC" }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yaml-language-server": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.23.0.tgz", + "integrity": "sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "ajv-i18n": "^4.2.0", + "prettier": "^3.8.1", + "request-light": "^0.5.7", + "vscode-json-languageservice": "4.1.8", + "vscode-languageserver": "^9.0.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "^3.16.0", + "vscode-uri": "^3.0.2", + "yaml": "2.8.3" + }, + "bin": { + "yaml-language-server": "bin/yaml-language-server" + } + }, + "node_modules/yaml-language-server/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/yaml-language-server/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml-language-server/node_modules/request-light": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz", + "integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml-language-server/node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "license": "ISC", "bin": { @@ -8639,14 +13159,57 @@ "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", "dev": true, "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, "engines": { - "node": ">=10" + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -8656,30 +13219,15 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", - "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "dev": true, "license": "MIT", "funding": { "type": "github", diff --git a/package.json b/package.json index 6a970e358..62eb3969f 100644 --- a/package.json +++ b/package.json @@ -1,82 +1,65 @@ { "name": "offon-website", - "private": true, - "version": "0.0.0", - "license": "MIT", - "packageManager": "npm@11.12.1", "type": "module", + "private": true, "scripts": { - "dev": "react-router dev", - "build": "react-router build", - "build:dev": "react-router build --mode development", + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "sync": "astro sync", + "test:unit": "vitest run", + "test:unit:watch": "vitest", + "test:unit:coverage": "vitest run --coverage", + "test:e2e": "playwright test", "lint": "eslint .", - "lint:reuse": "reuse lint", - "preview": "cp dist/client/404/index.html dist/client/404.html && npx serve dist/client", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", - "test:e2e": "playwright test --grep-invert visual", - "test:visual": "playwright test --grep visual", - "test:visual:update": "playwright test --grep visual --update-snapshots", - "generate": "node scripts/generate-adventures.mjs", - "generate:validate": "node scripts/generate-adventures.mjs --validate-only", - "generate:solutions": "node scripts/generate-solutions.mjs", - "generate:solutions:validate": "node scripts/generate-solutions.mjs --validate-only", - "prebuild": "node scripts/generate-adventures.mjs && node scripts/generate-solutions.mjs", - "postbuild": "node scripts/create-data-aliases.mjs", - "postbuild:dev": "node scripts/create-data-aliases.mjs" + "check": "astro check", + "lint:reuse": "reuse lint" }, - "dependencies": { - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "lucide-react": "^1.27.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-router": "^8.3.0", - "tailwind-merge": "^3.5.0" + "engines": { + "node": ">=26.0.0" }, "overrides": { "postcss": "^8.5.23", "fast-uri": "^3.1.4" }, - "engines": { - "node": ">=26.0.0" - }, - "devDependencies": { - "@axe-core/playwright": "^4.12.1", - "@eslint/js": "^10.0.1", - "@playwright/test": "^1.62.0", - "@react-router/dev": "^8.3.0", + "dependencies": { + "@astrojs/vue": "^7.0.1", + "@iconify-json/lucide": "^1.2.118", "@tailwindcss/vite": "^4.3.3", - "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^7.0.0", - "@testing-library/react": "^16.0.0", - "@types/node": "^26.0.1", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.1.0", - "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-v8": "^4.1.5", - "ajv": "^8.20.0", - "eslint": "^10.8.0", - "eslint-plugin-jsx-a11y": "^6.10.2", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.8.0", - "jsdom": "^29.1.1", - "jszip": "^3.10.1", + "astro": "^7.1.3", + "nanostores": "^1.4.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", - "reveal.js": "^6.0.1", + "shiki": "^4.4.3", "tailwindcss": "^4.3.3", - "typescript": "^6.0.3", - "typescript-eslint": "^8.64.0", "unified": "^11.0.5", - "vite": "^8.1.5", - "vitest": "^4.1.5", + "unplugin-icons": "^0.22.0", + "vue": "^3.5.40", "yaml": "^2.9.0" + }, + "devDependencies": { + "@astrojs/check": "^0.9.10", + "@axe-core/playwright": "^4.12.1", + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.62.0", + "@vitejs/plugin-vue": "^6.0.8", + "@vitest/coverage-v8": "^4.1.11", + "@vue/test-utils": "^2.4.6", + "eslint": "^10.8.0", + "eslint-plugin-astro": "^3.0.1", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-vue": "^10.10.0", + "eslint-plugin-vuejs-accessibility": "^2.6.0", + "globals": "^17.8.0", + "happy-dom": "^20.11.6", + "jszip": "^3.10.1", + "typescript": "^6.0.3", + "typescript-eslint": "^8.65.0", + "vitest": "^4.1.11", + "vue-eslint-parser": "^10.4.1" } } diff --git a/playwright.config.ts b/playwright.config.ts index 0f0cda23c..1c7dbaaf4 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,6 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; -// Requires a production build in dist/client/. Run `npm run build` first. +// Requires a production build in dist/. Run `npm run build` first (or the +// webServer's `astro preview` serves whatever is in dist/). export default defineConfig({ testDir: "e2e", fullyParallel: true, @@ -8,37 +9,23 @@ export default defineConfig({ retries: process.env.CI ? 1 : 0, reporter: "list", use: { - baseURL: "http://localhost:3000", + baseURL: "http://localhost:4321", trace: "on-first-retry", - // Pin the OS-level color scheme to dark so theme-toggle tests start from - // the site's default state regardless of the developer's OS setting. + // Pin OS color scheme to dark so theme-dependent tests start from the + // site default regardless of the runner's setting. colorScheme: "dark", }, - snapshotPathTemplate: "e2e/__screenshots__/{arg}{ext}", - expect: { - toHaveScreenshot: { - // Threshold: per-pixel color difference tolerance (0-1) - // 0.2 allows 20% color variation per pixel (tolerates font anti-aliasing) - threshold: 0.2, - // Max diff pixels: absolute count that can differ - // ~0.1% of a 1280×4689 page (6M pixels) = 6000 pixels - // Tolerates small UI changes (text updates, badge counts, etc.) - maxDiffPixels: 6000, - }, - }, - projects: [ - { name: "chromium", use: { ...devices["Desktop Chrome"] } }, - ], + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + // Astro 7 preview daemonizes by default (parent exits 0 immediately). + // Stop any leftover daemon first, start a fresh one with --background, then + // follow its logs so the webServer process stays alive for Playwright. + // globalTeardown stops the daemon after the suite finishes. + globalTeardown: "./e2e/teardown", webServer: { - // Use preview (not bare serve) so the 404 fallback copy runs before serving. - command: "npm run preview", - // Probe the root so the check works regardless of which adventures are - // prerendered. The root index.html is always present in dist/client/. - url: "http://localhost:3000/", - reuseExistingServer: !process.env.CI, - // 120s: CI runners are sometimes slow to start after Playwright browser - // install or artifact download, causing spurious "Timed out waiting 30000ms - // from config.webServer" failures on otherwise healthy shards. - timeout: 120000, + command: + "astro preview stop 2>/dev/null; astro preview --background && astro preview logs --follow", + url: "http://localhost:4321/", + reuseExistingServer: false, + timeout: 120_000, }, }); diff --git a/public/.well-known/agent-skills/index.json b/public/.well-known/agent-skills/index.json index 5b265d5ba..f8bb5c443 100644 --- a/public/.well-known/agent-skills/index.json +++ b/public/.well-known/agent-skills/index.json @@ -6,7 +6,7 @@ "type": "skill-md", "description": "Use when answering questions about the OffOn community, its open source challenges (adventures), how to participate, or when fetching challenge content and solutions.", "url": "https://offon.dev/.well-known/agent-skills/offon/SKILL.md", - "digest": "sha256:e1328b7e28b5c20f27f2a39e784aecc97effb9bbe8379a94a26fa3bed630b132" + "digest": "sha256:8a26b6eb664ae6297841792b5bda79f66b022f4c1d2160652d93b2984b159d34" } ] } diff --git a/public/.well-known/agent-skills/offon/SKILL.md b/public/.well-known/agent-skills/offon/SKILL.md index 979692791..c2b46fe1e 100644 --- a/public/.well-known/agent-skills/offon/SKILL.md +++ b/public/.well-known/agent-skills/offon/SKILL.md @@ -3,7 +3,7 @@ name: offon description: Use this skill when answering questions about the OffOn community, its open source challenges (adventures), how to participate, or when fetching challenge content and solutions. OffOn is a vendor-neutral community for open source enthusiasts that provides hands-on learning challenges. --- -# OffOn — Agent Skill +# OffOn: Agent Skill OffOn is a fully static, prerendered website. There is no API or backend. All content is available as plain HTML at canonical URLs and as a curated index at `/llms.txt`. diff --git a/src/assets/Dynatrace_Logo_color_negative_horizontal.svg b/public/brand/Dynatrace_Logo_color_negative_horizontal.svg similarity index 100% rename from src/assets/Dynatrace_Logo_color_negative_horizontal.svg rename to public/brand/Dynatrace_Logo_color_negative_horizontal.svg diff --git a/src/assets/Dynatrace_Logo_color_positive_horizontal.svg b/public/brand/Dynatrace_Logo_color_positive_horizontal.svg similarity index 100% rename from src/assets/Dynatrace_Logo_color_positive_horizontal.svg rename to public/brand/Dynatrace_Logo_color_positive_horizontal.svg diff --git a/public/llms-full.txt b/public/llms-full.txt index ce839749a..1569e22f8 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -1,4 +1,4 @@ -# OffOn — Full Content Index +# OffOn: Full Content Index > OffOn is a vendor-neutral community for open source enthusiasts. > Learn through hands-on challenges, share knowledge, and build together. @@ -50,15 +50,15 @@ The Roman Republic has built a sophisticated legal system to protect its citizen **Levels:** -- **Beginner — The Twelve Tables** (https://offon.dev/adventures/lex-imperfecta/levels/beginner/) +- **Beginner: The Twelve Tables** (https://offon.dev/adventures/lex-imperfecta/levels/beginner/) Topics: Kyverno, Kubernetes Learn to write and enforce Kubernetes admission policies using Kyverno. -- **Intermediate — Governing the Provinces** (https://offon.dev/adventures/lex-imperfecta/levels/intermediate/) +- **Intermediate: Governing the Provinces** (https://offon.dev/adventures/lex-imperfecta/levels/intermediate/) Topics: Kyverno, Policy Reporter, Kubernetes Layer exceptions and reporting onto your policy set; understand why policies fail silently. -- **Expert — Quis Custodiet** (https://offon.dev/adventures/lex-imperfecta/levels/expert/) +- **Expert: Quis Custodiet** (https://offon.dev/adventures/lex-imperfecta/levels/expert/) Topics: Kyverno, Policy Reporter, Kubernetes Harden policies against evasion; instrument and report on compliance at scale. @@ -74,15 +74,15 @@ Three levels of OpenFeature with flagd as the provider, in a Java + Spring Boot **Levels:** -- **Beginner — Stand up the Lab** (https://offon.dev/adventures/blind-by-design/levels/beginner/) +- **Beginner: Stand up the Lab** (https://offon.dev/adventures/blind-by-design/levels/beginner/) Topics: OpenFeature, flagd, Spring Boot Wire the OpenFeature SDK to a flagd sidecar and toggle behaviour with a flag. -- **Intermediate — Outcome by Cohort** (https://offon.dev/adventures/blind-by-design/levels/intermediate/) +- **Intermediate: Outcome by Cohort** (https://offon.dev/adventures/blind-by-design/levels/intermediate/) Topics: OpenFeature, flagd, Spring Boot, Java Use evaluation context to target feature flags at specific user cohorts. -- **Expert — Read the Chart** (https://offon.dev/adventures/blind-by-design/levels/expert/) +- **Expert: Read the Chart** (https://offon.dev/adventures/blind-by-design/levels/expert/) Topics: OpenFeature, OpenTelemetry, Grafana, Spring Boot Instrument flag evaluations with OpenTelemetry; detect and roll back a fractional rollout causing errors. @@ -98,15 +98,15 @@ Investigate a mysterious bandwidth anomaly at a remote research station by instr **Levels:** -- **Beginner — Calibrating the Lens** (https://offon.dev/adventures/the-ai-observatory/levels/beginner/) +- **Beginner: Calibrating the Lens** (https://offon.dev/adventures/the-ai-observatory/levels/beginner/) Topics: OpenTelemetry, OpenLLMetry, Jaeger Add observability to an LLM-based system using OpenLLMetry and trace requests through Jaeger. -- **Intermediate — The Distracted Pilot** (https://offon.dev/adventures/the-ai-observatory/levels/intermediate/) +- **Intermediate: The Distracted Pilot** (https://offon.dev/adventures/the-ai-observatory/levels/intermediate/) Topics: OpenTelemetry, OpenLLMetry, Jaeger, Prometheus Correlate traces and metrics; identify the source of performance degradation. -- **Expert — The Noise Filter** (https://offon.dev/adventures/the-ai-observatory/levels/expert/) +- **Expert: The Noise Filter** (https://offon.dev/adventures/the-ai-observatory/levels/expert/) Topics: OpenTelemetry, OpenLLMetry, Jaeger Implement sampling strategies; reduce telemetry noise without losing signal. @@ -122,20 +122,44 @@ Join the Infrastructure Guild and modernize CloudHaven's infrastructure from man **Levels:** -- **Beginner — The Foundation Stones** (https://offon.dev/adventures/building-cloudhaven/levels/beginner/) +- **Beginner: The Foundation Stones** (https://offon.dev/adventures/building-cloudhaven/levels/beginner/) Topics: OpenTofu Provision infrastructure with OpenTofu; understand state, resources, and the plan/apply cycle. -- **Intermediate — The Modular Metropolis** (https://offon.dev/adventures/building-cloudhaven/levels/intermediate/) +- **Intermediate: The Modular Metropolis** (https://offon.dev/adventures/building-cloudhaven/levels/intermediate/) Topics: OpenTofu, TDD Refactor infrastructure into reusable modules; apply test-driven development to infrastructure code. -- **Expert — The Guardian Protocols** (https://offon.dev/adventures/building-cloudhaven/levels/expert/) +- **Expert: The Guardian Protocols** (https://offon.dev/adventures/building-cloudhaven/levels/expert/) Topics: OpenTofu, GitHub Actions, Trivy Automate infrastructure delivery with CI/CD; scan for security misconfigurations with Trivy. --- +### Dead Reckoning + +**URL:** https://offon.dev/adventures/dead-reckoning/ + +The Grand Fleet's commission office is buried in complaints. Manifests are filed but nothing comes of them. Vessels that do sail arrive at port with the wrong cargo, and no one along the route can explain why. As the fleet's engineer, your mission is to restore order from keel to quayside and find out what the records are hiding. + +**Technologies:** Backstage, Gitea, Argo Events, Argo Workflows, Argo CD, OpenTelemetry, Jaeger + +**Levels:** + +- **Beginner: Laying the Keel** (https://offon.dev/adventures/dead-reckoning/levels/beginner/) + Topics: Backstage, Gitea + Fix a broken Backstage software template so the commission office can register new vessels for service. + +- **Intermediate: Sea Trial** (https://offon.dev/adventures/dead-reckoning/levels/intermediate/) + Topics: Backstage, Gitea, Argo Events, Argo Workflows, Argo CD + Fix the broken integration points in the delivery pipeline so that commissioning a vessel results in a running deployment. + +- **Expert: The Chronometer** (https://offon.dev/adventures/dead-reckoning/levels/expert/) + Topics: Backstage, Argo Workflows, Argo CD, OpenTelemetry, Jaeger + Repair the fleet's broken navigation log, then use the complete trace it produces to find why vessels arrive carrying the wrong cargo. + +--- + ### Echoes Lost in Orbit **URL:** https://offon.dev/adventures/echoes-lost-in-orbit/ @@ -146,17 +170,17 @@ Restore interstellar communications by fixing broken GitOps setups, progressive **Levels:** -- **Beginner — Broken Echoes** (https://offon.dev/adventures/echoes-lost-in-orbit/levels/beginner/) +- **Beginner: Broken Echoes** (https://offon.dev/adventures/echoes-lost-in-orbit/levels/beginner/) Topics: Argo CD Fix a broken GitOps pipeline; restore continuous delivery via Argo CD. Solution: https://offon.dev/adventures/echoes-lost-in-orbit/levels/beginner/solution/ -- **Intermediate — The Silent Canary** (https://offon.dev/adventures/echoes-lost-in-orbit/levels/intermediate/) +- **Intermediate: The Silent Canary** (https://offon.dev/adventures/echoes-lost-in-orbit/levels/intermediate/) Topics: Argo Rollouts, PromQL Diagnose a failed canary deployment using PromQL; configure automated rollback rules. Solution: https://offon.dev/adventures/echoes-lost-in-orbit/levels/intermediate/solution/ -- **Expert — Hyperspace Operations & Transport** (https://offon.dev/adventures/echoes-lost-in-orbit/levels/expert/) +- **Expert: Hyperspace Operations & Transport** (https://offon.dev/adventures/echoes-lost-in-orbit/levels/expert/) Topics: Argo Rollouts, OpenTelemetry, Jaeger, PromQL Correlate traces with deployment events; instrument a progressive delivery pipeline end-to-end. Solution: https://offon.dev/adventures/echoes-lost-in-orbit/levels/expert/solution/ diff --git a/public/llms.txt b/public/llms.txt index 140348c2f..f63ad9a82 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -11,6 +11,7 @@ - [Challenges](https://offon.dev/challenges/): Browse all challenge levels; filter by technology tag. - [Community Guide / Handbook](https://offon.dev/handbook/): Getting started guide for the community. - [About](https://offon.dev/about/): What OffOn is and who it is for. +- [Contribute](https://offon.dev/contribute/): How to submit solutions, participate in adventures, and contribute to the community. - [Sponsors](https://offon.dev/sponsors/): Organisations supporting the platform. - [Accessibility Statement](https://offon.dev/accessibility/): WCAG 2.2 AA commitment and known issues. - [Brand Guidelines](https://offon.dev/brand/): Logos, colors, typography, and voice for OffOn. @@ -21,10 +22,25 @@ Each adventure is a scenario-driven challenge with beginner, intermediate, and expert levels. - [Dead Reckoning](https://offon.dev/adventures/dead-reckoning/): The Grand Fleet's commission office is buried in complaints. Manifests are filed but nothing comes of them. Vessels that do sail arrive at port with the wrong cargo, and no one along the route can explain why. As the fleet's engineer, your mission is to restore order from keel to quayside and find out what the records are hiding. -- [Lex Imperfecta](https://offon.dev/adventures/lex-imperfecta/): 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. + - [Beginner](https://offon.dev/adventures/dead-reckoning/levels/beginner/) + - [Intermediate](https://offon.dev/adventures/dead-reckoning/levels/intermediate/) + - [Expert](https://offon.dev/adventures/dead-reckoning/levels/expert/) +- [Lex Imperfecta](https://offon.dev/adventures/lex-imperfecta/): 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. + - [Beginner](https://offon.dev/adventures/lex-imperfecta/levels/beginner/) + - [Intermediate](https://offon.dev/adventures/lex-imperfecta/levels/intermediate/) + - [Expert](https://offon.dev/adventures/lex-imperfecta/levels/expert/) - [Blind by Design](https://offon.dev/adventures/blind-by-design/): 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 (Intermediate), then instrument flag evaluations with OpenTelemetry and roll back a misbehaving fractional rollout (Expert). All without redeploying. + - [Beginner](https://offon.dev/adventures/blind-by-design/levels/beginner/) + - [Intermediate](https://offon.dev/adventures/blind-by-design/levels/intermediate/) + - [Expert](https://offon.dev/adventures/blind-by-design/levels/expert/) - [The AI Observatory](https://offon.dev/adventures/the-ai-observatory/): Investigate a mysterious bandwidth anomaly at a remote research station by instrumenting its AI system with OpenTelemetry, OpenLLMetry, and Jaeger. + - [Beginner](https://offon.dev/adventures/the-ai-observatory/levels/beginner/) + - [Intermediate](https://offon.dev/adventures/the-ai-observatory/levels/intermediate/) + - [Expert](https://offon.dev/adventures/the-ai-observatory/levels/expert/) - [Building CloudHaven](https://offon.dev/adventures/building-cloudhaven/): 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. + - [Beginner](https://offon.dev/adventures/building-cloudhaven/levels/beginner/) + - [Intermediate](https://offon.dev/adventures/building-cloudhaven/levels/intermediate/) + - [Expert](https://offon.dev/adventures/building-cloudhaven/levels/expert/) - [Echoes Lost in Orbit](https://offon.dev/adventures/echoes-lost-in-orbit/): Restore interstellar communications by fixing broken GitOps setups, progressive delivery systems, and observability pipelines across three galactic missions. - [Broken Echoes solution](https://offon.dev/adventures/echoes-lost-in-orbit/levels/beginner/solution/) - [The Silent Canary solution](https://offon.dev/adventures/echoes-lost-in-orbit/levels/intermediate/solution/) @@ -32,8 +48,9 @@ Each adventure is a scenario-driven challenge with beginner, intermediate, and e ## Challenge Technologies -ArgoCD, Argo Rollouts, flagd, GitHub Actions, Grafana, Jaeger, Java, OpenFeature, -OpenLLMetry, OpenTelemetry, OpenTofu, Prometheus, PromQL, Python, Spring Boot, +Argo CD, Argo Events, Argo Rollouts, Argo Workflows, Backstage, flagd, Gitea, +GitHub Actions, Grafana, Jaeger, Java, Kubernetes, Kyverno, OpenFeature, OpenLLMetry, +OpenTelemetry, OpenTofu, Policy Reporter, Prometheus, PromQL, Python, Spring Boot, TDD, Terraform, Trivy. ## Machine-Readable Resources diff --git a/public/robots.txt b/public/robots.txt index 74e7aad35..a76fc7a55 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -7,7 +7,7 @@ Disallow: /presentation-templates/ Sitemap: https://offon.dev/sitemap.xml Sitemap: https://offon.dev/community-sitemap.xml -# AI content crawlers — allow discovery; content may not be used for training +# AI content crawlers - allow discovery; content may not be used for training User-agent: GPTBot Allow: / Disallow: /deck/ diff --git a/public/sitemap.xml b/public/sitemap.xml deleted file mode 100644 index 55bfcf452..000000000 --- a/public/sitemap.xml +++ /dev/null @@ -1,68 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> - <url><loc>https://offon.dev/</loc><lastmod>2026-06-01</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url> - <url><loc>https://offon.dev/adventures/</loc><lastmod>2026-06-04</lastmod><changefreq>weekly</changefreq><priority>0.9</priority></url> - <url><loc>https://offon.dev/contribute/</loc><lastmod>2026-06-02</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/sponsors/</loc><lastmod>2026-06-01</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/about/</loc><lastmod>2026-06-01</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/handbook/</loc><lastmod>2026-06-01</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/accessibility/</loc><lastmod>2026-06-01</lastmod><changefreq>yearly</changefreq><priority>0.5</priority></url> - <url><loc>https://offon.dev/brand/</loc><lastmod>2026-06-05</lastmod><changefreq>yearly</changefreq><priority>0.4</priority></url> - <!-- GENERATED:adventures --> - <url><loc>https://offon.dev/adventures/dead-reckoning/</loc><lastmod>2026-07-21</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/dead-reckoning/levels/beginner/</loc><lastmod>2026-07-21</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/dead-reckoning/levels/intermediate/</loc><lastmod>2026-07-21</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/dead-reckoning/levels/expert/</loc><lastmod>2026-07-21</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/lex-imperfecta/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/lex-imperfecta/levels/beginner/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/lex-imperfecta/levels/intermediate/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/lex-imperfecta/levels/expert/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/blind-by-design/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/blind-by-design/levels/beginner/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/blind-by-design/levels/intermediate/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/blind-by-design/levels/expert/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/the-ai-observatory/</loc><lastmod>2026-06-30</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/the-ai-observatory/levels/beginner/</loc><lastmod>2026-06-30</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/the-ai-observatory/levels/intermediate/</loc><lastmod>2026-06-30</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/the-ai-observatory/levels/expert/</loc><lastmod>2026-06-30</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/building-cloudhaven/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/building-cloudhaven/levels/beginner/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/building-cloudhaven/levels/intermediate/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/building-cloudhaven/levels/expert/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/echoes-lost-in-orbit/</loc><lastmod>2026-05-31</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/echoes-lost-in-orbit/levels/beginner/</loc><lastmod>2026-05-31</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/echoes-lost-in-orbit/levels/beginner/solution/</loc><lastmod>2026-05-31</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/adventures/echoes-lost-in-orbit/levels/intermediate/</loc><lastmod>2026-05-31</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/echoes-lost-in-orbit/levels/intermediate/solution/</loc><lastmod>2026-05-31</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/adventures/echoes-lost-in-orbit/levels/expert/</loc><lastmod>2026-05-31</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url> - <url><loc>https://offon.dev/adventures/echoes-lost-in-orbit/levels/expert/solution/</loc><lastmod>2026-05-31</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <!-- /GENERATED:adventures --> - <url><loc>https://offon.dev/challenges/</loc><lastmod>2026-06-03</lastmod><changefreq>weekly</changefreq><priority>0.9</priority></url> - <!-- GENERATED:challenge-tags --> - <url><loc>https://offon.dev/challenges/argo-cd/</loc><lastmod>2026-07-21</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/argo-events/</loc><lastmod>2026-07-21</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/argo-rollouts/</loc><lastmod>2026-05-31</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/argo-workflows/</loc><lastmod>2026-07-21</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/backstage/</loc><lastmod>2026-07-21</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/flagd/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/gitea/</loc><lastmod>2026-07-21</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/github-actions/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/grafana/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/jaeger/</loc><lastmod>2026-07-08</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/java/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/kubernetes/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/kyverno/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/openfeature/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/openllmetry/</loc><lastmod>2026-06-30</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/opentelemetry/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/opentofu/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/policy-reporter/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/prometheus/</loc><lastmod>2026-06-30</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/promql/</loc><lastmod>2026-05-31</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/python/</loc><lastmod>2026-06-30</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/spring-boot/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/tdd/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/terraform/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> - <url><loc>https://offon.dev/challenges/trivy/</loc><lastmod>2026-07-15</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url> -<!-- /GENERATED:challenge-tags --> -</urlset> diff --git a/react-router.config.ts b/react-router.config.ts deleted file mode 100644 index 1687d142a..000000000 --- a/react-router.config.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { Config } from "@react-router/dev/config"; - -export default { - ssr: false, - appDirectory: "src", - buildDirectory: "dist", - basename: process.env.VITE_BASE_PATH ?? "/", - prerender: [ - "/", - "/adventures", - "/404", - "/contribute", - "/sponsors", - "/about", - "/handbook", - "/privacy", - "/accessibility", - "/brand", - "/presentation-templates", - // GENERATED:adventures - "/adventures/dead-reckoning", - "/adventures/dead-reckoning/levels/beginner", - "/adventures/dead-reckoning/levels/intermediate", - "/adventures/dead-reckoning/levels/expert", - "/adventures/lex-imperfecta", - "/adventures/lex-imperfecta/levels/beginner", - "/adventures/lex-imperfecta/levels/intermediate", - "/adventures/lex-imperfecta/levels/expert", - "/adventures/blind-by-design", - "/adventures/blind-by-design/levels/beginner", - "/adventures/blind-by-design/levels/intermediate", - "/adventures/blind-by-design/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/building-cloudhaven", - "/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", - // /GENERATED:adventures - // GENERATED:solutions - "/adventures/echoes-lost-in-orbit/levels/beginner/solution", - "/adventures/echoes-lost-in-orbit/levels/expert/solution", - "/adventures/echoes-lost-in-orbit/levels/intermediate/solution", - // /GENERATED:solutions - "/challenges", - // GENERATED:challenge-tags - "/challenges/argo-cd", - "/challenges/argo-events", - "/challenges/argo-rollouts", - "/challenges/argo-workflows", - "/challenges/backstage", - "/challenges/flagd", - "/challenges/gitea", - "/challenges/github-actions", - "/challenges/grafana", - "/challenges/jaeger", - "/challenges/java", - "/challenges/kubernetes", - "/challenges/kyverno", - "/challenges/openfeature", - "/challenges/openllmetry", - "/challenges/opentelemetry", - "/challenges/opentofu", - "/challenges/policy-reporter", - "/challenges/prometheus", - "/challenges/promql", - "/challenges/python", - "/challenges/spring-boot", - "/challenges/tdd", - "/challenges/terraform", - "/challenges/trivy", - // /GENERATED:challenge-tags - ], -} satisfies Config; diff --git a/schemas/adventure.schema.json b/schemas/adventure.schema.json deleted file mode 100644 index 690aaa277..000000000 --- a/schemas/adventure.schema.json +++ /dev/null @@ -1,307 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://offon.dev/schemas/adventure.schema.json", - "title": "Adventure", - "description": "Schema for an OffOn adventure YAML file.", - "type": "object", - "required": ["slug", "title", "month", "tags", "levels"], - "additionalProperties": false, - "properties": { - "slug": { - "type": "string", - "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": "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 (e.g. 'FlaskConical'). Overrides emoji-derived icon when both are present." - }, - "month": { - "type": "string", - "pattern": "^[A-Z]{3} \\d{4}$", - "description": "Release month in format 'MMM YYYY' (e.g. 'MAY 2026')." - }, - "story": { - "type": "string", - "description": "One-paragraph card summary. Omit to derive from backstory[0]." - }, - "tags": { - "type": "array", - "items": { "type": "string" }, - "minItems": 1, - "description": "Technology tags (e.g. ['OpenFeature', 'Spring Boot'])." - }, - "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." - }, - "meta_description": { - "type": "string", - "maxLength": 160, - "description": "Overrides the auto-generated adventure meta description. Use when the default (built from tags) is misleading — e.g. when early tags belong to future levels." - }, - "backstory": { - "type": "array", - "items": { "type": "string" }, - "description": "Narrative backstory paragraphs shown on the adventure overview page." - }, - "overview": { - "type": "array", - "items": { "type": "string" }, - "description": "Context paragraphs explaining what technologies or concepts the adventure covers. Rendered as a bulleted list under 'Your Mission'." - }, - "rewards": { - "$ref": "#/$defs/rewards" - }, - "upcoming_levels": { - "type": "array", - "items": { "$ref": "#/$defs/upcomingLevel" }, - "description": "Placeholder levels that haven't shipped yet." - }, - "levels": { - "type": "array", - "items": { "$ref": "#/$defs/level" }, - "minItems": 1, - "description": "The challenge levels within this adventure." - } - }, - "$defs": { - "contributor": { - "type": "object", - "required": ["name"], - "additionalProperties": false, - "properties": { - "name": { "type": "string" }, - "url": { "type": "string", "format": "uri" }, - "about": { "type": "string" } - } - }, - "rewards": { - "type": "object", - "required": ["deadline", "tiers"], - "additionalProperties": false, - "properties": { - "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", - "items": { - "type": "object", - "required": ["label", "description"], - "additionalProperties": false, - "properties": { - "label": { "type": "string" }, - "description": { "type": "string" } - } - } - }, - "ranking_note": { "type": "string", "description": "Omit to use the standard ranking note." }, - "ranking_rules_url": { "type": "string", "description": "Omit to use the default community ranking rules path." } - } - }, - "upcomingLevel": { - "type": "object", - "required": ["name", "difficulty"], - "additionalProperties": false, - "properties": { - "level": { "type": "string", "description": "Level identifier (beginner, intermediate, expert). Emitted by the sync script so the entry survives re-syncs; may be absent in hand-written entries." }, - "name": { "type": "string" }, - "difficulty": { - "type": "string", - "enum": ["Beginner", "Intermediate", "Expert"] - } - } - }, - "level": { - "type": "object", - "required": ["level", "name", "topics", "devcontainer", "objective", "toolbox", "how_to_play", "verification"], - "additionalProperties": false, - "properties": { - "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"], - "description": "Explicit difficulty. Omit when using emoji." - }, - "topics": { - "type": "array", - "items": { "type": "string" } - }, - "learnings": { - "type": "array", - "items": { "type": "string" }, - "minItems": 1, - "description": "Key learnings. Use this OR what_you_learn." - }, - "what_you_learn": { - "type": "array", - "items": { "type": "string" }, - "minItems": 1, - "description": "Alias for learnings." - }, - "devcontainer": { - "type": "string", - "description": "Short devcontainer name (e.g. 'lex-imperfecta_beginner'). Generator expands to '.devcontainer/{value}/devcontainer.json'." - }, - "codespaces_machine": { - "type": "string", - "enum": ["4core"], - "description": "Request a larger Codespaces machine for this level. '4core' maps to standardLinux32gb (4-core, 16 GB). Omit for the default 2-core machine." - }, - "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" }, - "description": "Brief intro paragraph(s). Use this OR summary." - }, - "backstory": { - "type": "array", - "items": { "type": "string" } - }, - "objective": { - "type": "array", - "items": { "type": "string" } - }, - "audience": { "type": "string" }, - "estimated_time": { - "type": "string", - "description": "Optional estimated completion time shown as a pill, e.g. '~30 min' or '1–2 hours'." - }, - "scenario": { "type": "string" }, - "architecture": { - "type": "array", - "items": { "type": "string" } - }, - "architecture_diagram": { - "type": "string", - "description": "Filename of the SVG diagram in src/assets/diagrams/ (e.g. 'blind-by-design-intermediate.svg'). Takes priority over architecture_ascii." - }, - "diagram_alt": { "type": "string" }, - "architecture_ascii": { - "type": "string", - "description": "ASCII art diagram rendered as a <pre> block when no SVG diagram is available. Use a YAML block scalar (|) to preserve whitespace." - }, - "toolbox": { - "type": "array", - "items": { - "type": "object", - "required": ["name", "description"], - "additionalProperties": false, - "properties": { - "name": { "type": "string" }, - "description": { "type": "string" }, - "url": { "type": "string", "format": "uri" } - } - } - }, - "services": { - "type": "array", - "description": "Services accessible in the Codespace. The generator inserts an 'Explore the UIs' how_to_play step from this list. Use internal: true for services reachable only on the docker-internal network.", - "items": { - "type": "object", - "required": ["name", "description"], - "additionalProperties": false, - "properties": { - "name": { "type": "string" }, - "port": { "type": ["string", "integer"] }, - "credentials": { "type": "string" }, - "description": { "type": "string" }, - "internal": { "type": "boolean" } - } - } - }, - "how_to_play": { - "type": "array", - "items": { - "type": "object", - "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." } - } - } - }, - "verification": { - "type": "object", - "required": ["command", "description"], - "additionalProperties": false, - "properties": { - "command": { "type": "string" }, - "description": { "type": "string" } - } - }, - "helpful_links": { - "type": "array", - "items": { - "type": "object", - "required": ["title", "url"], - "additionalProperties": false, - "properties": { - "title": { "type": "string" }, - "url": { "type": "string", "format": "uri" }, - "description": { "type": "string" } - } - }, - "description": "Reference documentation links shown at the end of the challenge walkthrough." - }, - "meta_description": { - "type": "string", - "maxLength": 160, - "description": "Optional SEO meta description (max 160 chars). Use when the auto-generated description from learnings is insufficient. If omitted, the generator builds one from the level name, learnings, difficulty, and adventure title." - }, - "solved_count": { "type": "integer" }, - "top_players": { - "type": "array", - "items": { - "type": "object", - "required": ["username", "count"], - "additionalProperties": false, - "properties": { - "username": { "type": "string" }, - "count": { "type": "integer" } - } - } - } - } - } - } -} diff --git a/scripts/check-docs.sh b/scripts/check-docs.sh index e17ac48cc..c02dbb895 100755 --- a/scripts/check-docs.sh +++ b/scripts/check-docs.sh @@ -17,21 +17,18 @@ REASONS_README=() while IFS=$'\t' read -r status file; do if [[ "$status" == "A" ]]; then case "$file" in - src/components/*.tsx|src/hooks/*.ts|src/hooks/*.tsx|src/lib/*.ts) - # Exclude shadcn primitives — they are managed by npx shadcn@latest, not documented manually. - if [[ "$file" != src/components/ui/* ]]; then - NEEDS_STYLEGUIDE=1 - REASONS_STYLE+=("New file: $file") - fi + src/components/*.astro|src/components/*.vue|src/lib/*.ts) + NEEDS_STYLEGUIDE=1 + REASONS_STYLE+=("New file: $file") ;; esac fi done < <(git diff --name-status "$BASE"...HEAD) -# New exported constants in constants.ts require a README.md entry. -if git diff "$BASE"...HEAD -- src/data/constants.ts | grep -qE '^\+export const [A-Z_]+'; then +# New exported constants in site.ts require a README.md entry. +if git diff "$BASE"...HEAD -- src/lib/site.ts | grep -qE '^\+export const [A-Z_]+'; then NEEDS_README=1 - REASONS_README+=("New export(s) in src/data/constants.ts") + REASONS_README+=("New export(s) in src/lib/site.ts") fi # New npm scripts require a README.md entry. diff --git a/scripts/create-data-aliases.mjs b/scripts/create-data-aliases.mjs deleted file mode 100644 index d707c4bbc..000000000 --- a/scripts/create-data-aliases.mjs +++ /dev/null @@ -1,45 +0,0 @@ -/** - * After the React Router build, each prerendered route with a loader produces - * a `<path>.data` file for non-trailing-slash single-fetch requests. GitHub - * Pages normalises every URL to have a trailing slash, so client-side - * navigation from a GitHub Pages URL triggers `<path>/_.data` instead. - * - * This script copies every `*.data` file to `<name>/_.data` so both formats - * resolve correctly without changing any Link `to` props. - */ -import { readdir, copyFile, mkdir } from "node:fs/promises"; -import { join, dirname, basename } from "node:path"; - -const buildDir = "dist/client"; - -let entries; -try { - entries = await readdir(buildDir, { withFileTypes: true, recursive: true }); -} catch (err) { - if (err.code === "ENOENT") { - console.error(`create-data-aliases: '${buildDir}' not found — run 'npm run build' first`); - process.exit(1); - } - throw err; -} - -// Exclude _.data files so repeated runs don't create _/_.data chains. -const dataFiles = entries - .filter((e) => e.isFile() && e.name.endsWith(".data") && e.name !== "_.data") - .map((e) => join(e.parentPath, e.name)); - -await Promise.all( - dataFiles.map(async (file) => { - const aliasDir = join(dirname(file), basename(file, ".data")); - const alias = join(aliasDir, "_.data"); - await mkdir(aliasDir, { recursive: true }); - await copyFile(file, alias); - }) -); - -if (dataFiles.length === 0) { - console.error("create-data-aliases: no *.data files found in dist/client — loaders may have been accidentally removed"); - process.exit(1); -} - -console.log(`Created ${dataFiles.length} _.data alias${dataFiles.length === 1 ? "" : "es"}`); diff --git a/scripts/discourse-utils.mjs b/scripts/discourse-utils.mjs new file mode 100644 index 000000000..6af7b01d0 --- /dev/null +++ b/scripts/discourse-utils.mjs @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2025 OffOn contributors +// SPDX-License-Identifier: MIT + +/** + * Shared utilities for Discourse data refresh scripts. + * Exported functions are unit-tested in src/test/scripts/discourse-utils.test.ts. + * + * Staleness window: the refresh-community-data workflow runs hourly. All + * Discourse data (posts, leaderboard, community leaders) may therefore be up + * to 60 minutes stale. This is accepted and documented — it is sufficient for + * community activity feeds that are not time-critical. + * + * Admin-key requirement: refresh-leaderboard.mjs and refresh-community-leaders.mjs + * query the Discourse Data Explorer, which requires a Discourse admin API key + * (DISCOURSE_API_KEY env var). refresh-discussions.mjs uses the public Discourse + * topic API and requires no credentials. + */ + +import { writeFileSync, renameSync } from "node:fs"; + +/** + * Write content to `path` atomically by writing to `<path>.tmp` then renaming. + * If the process dies between the write and the rename, `path` is unaffected + * and the orphaned `.tmp` can be safely deleted on the next run. + */ +export function atomicWrite(path, content) { + const tmp = `${path}.tmp`; + writeFileSync(tmp, content, "utf-8"); + renameSync(tmp, path); +} + +/** + * Fetch wrapper that retries on HTTP 429 (rate-limited) responses. + * Reads the `Retry-After` response header; falls back to 60 s when absent. + * Caps the wait at 120 s to avoid stalling CI runs indefinitely. + * Returns the final Response — caller inspects `res.ok` / `res.status`. + */ +export async function fetchWithRetry(url, options = {}, maxRetries = 3) { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const res = await fetch(url, options); + if (res.status !== 429 || attempt === maxRetries) return res; + const header = res.headers.get("Retry-After"); + const seconds = Math.min(parseInt(header ?? "60", 10) || 60, 120); + console.warn(` Rate-limited (429). Waiting ${seconds}s before retry ${attempt + 1}/${maxRetries}…`); + await new Promise((r) => setTimeout(r, seconds * 1000)); + } + return fetch(url, options); // unreachable; satisfies static analysis +} diff --git a/scripts/generate-adventures.mjs b/scripts/generate-adventures.mjs deleted file mode 100644 index 1630cc1f9..000000000 --- a/scripts/generate-adventures.mjs +++ /dev/null @@ -1,1428 +0,0 @@ -#!/usr/bin/env node - -/** - * Generate TypeScript adventure files from YAML sources. - * - * Usage: - * node scripts/generate-adventures.mjs [--validate-only] - * - * Reads all src/data/adventures/<id>/adventure.yaml files and generates - * the corresponding <id>.generated.ts files plus index.ts with the correct - * imports and ADVENTURES array. - * - * With --validate-only, parses and validates YAML without writing files. - * - * Why YAML + generated TS instead of authoring TS directly? - * - YAML is easier to write and review for non-engineers, and is validated - * by JSON Schema (schemas/adventure.schema.json) before generation. - * - Vite cannot import YAML natively, so the generator converts each file - * to fully-typed TypeScript that the app can statically import and - * tree-shake. - * - The generated files are committed so the build works without running - * this script first. CI can detect out-of-sync output by running - * `npm run generate` and checking for a clean git diff. - * - Never edit *.generated.ts by hand — changes will be overwritten. - * - * Devcontainer validation and auto-correction: - * - In generate mode, each level's devcontainer value is cross-checked - * against the actual folder names in the challenges repo - * (off-on-dev/open-source-challenges/.devcontainer) via gh api. - * - If a value is wrong but an unambiguous match can be found by slug and - * difficulty, the YAML file is patched in place and a warning is emitted. - * The corrected value should also be fixed upstream in the challenges repo. - * - In --validate-only mode, wrong values are always errors (CI must not - * silently mutate source files). - * - Uses the GitHub REST API via native fetch (no auth required for public - * repos). Falls back gracefully if the network is unavailable. - */ - -import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { parse as parseYaml } from "yaml"; -import { unified } from "unified"; -import remarkParse from "remark-parse"; -import remarkGfm from "remark-gfm"; -import remarkRehype from "remark-rehype"; -import rehypeRaw from "rehype-raw"; -import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; -import rehypeStringify from "rehype-stringify"; -import Ajv2020 from "ajv/dist/2020.js"; -import { LEVEL_DIFFICULTY_BY_EMOJI } from "./lib/level-constants.mjs"; -import { parseDeadline } from "./lib/deadline.mjs"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, ".."); -const ADVENTURES_DIR = resolve(ROOT, "src/data/adventures"); -const SCHEMA_PATH = resolve(ROOT, "schemas/adventure.schema.json"); - -// Duplicated from src/data/constants.ts — scripts run in Node outside the Vite build and cannot import from src/. -const BRAND_NAME = "OffOn"; - -// Computed once at startup so all date comparisons within a single run are consistent. -const TODAY = new Date().toISOString().slice(0, 10); - -const validateOnly = process.argv.includes("--validate-only"); - -// JSON Schema validator — catches structural issues the custom validateAdventure() -// doesn't check: unknown fields (additionalProperties), pattern mismatches (month -// format, slug format), enum violations, and length constraints. -const adventureSchema = JSON.parse(readFileSync(SCHEMA_PATH, "utf8")); -const ajv = new Ajv2020({ allErrors: true, strict: false }); -ajv.addFormat("uri", (value) => { try { new URL(value); return true; } catch { return false; } }); -const schemaValidate = ajv.compile(adventureSchema); - -// Keywords whose AJV violations are reported with better messages by the custom validator. -// "enum" is handled separately in schemaErrors(): only difficulty paths are skipped, so any -// future enum field added to the schema still gets AJV coverage. -const SCHEMA_SKIP_KEYWORDS = new Set(["required", "type", "minItems", "if", "then", "else"]); - -// Maps emoji shorthand to Lucide React icon names used in AdventureIcon.tsx. -const EMOJI_ICON_MAP = { - "🧪": "FlaskConical", - "🔭": "Telescope", - "☁️": "Cloud", - "🛰️": "Satellite", - "⚖️": "Scale", - "🧭": "Compass", -}; - -// Must stay in sync with the ICONS map in src/components/AdventureIcon.tsx. -// A name absent from this set is written to the generated file but AdventureIcon silently returns null. -const VALID_ICONS = new Set([...Object.values(EMOJI_ICON_MAP), "Building2"]); - - -// 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."; -const DEFAULT_REWARDS_RANKING_NOTE = - "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)."; -const DEFAULT_REWARDS_RANKING_RULES_PATH = "/t/about-the-challenges-category/16"; - -// --- Build-time markdown-to-HTML pipeline --- - -const sanitizeSchema = { - ...defaultSchema, - attributes: { - ...defaultSchema.attributes, - // Preserve all default <a> attrs (including ARIA) and add target/rel for external links. - a: [...(defaultSchema.attributes?.a ?? []), "target", "rel"], - code: ["className"], - }, - tagNames: [ - ...(defaultSchema.tagNames ?? []), - "pre", - "code", - "abbr", - ], - // Drop <style> tag content; rehypeSanitize strips the element but passes - // its text children through by default (only <script> is in strip[]). - strip: [...(defaultSchema.strip ?? []), "style"], -}; - -// Unique id counter for abbr expansion spans, shared across the whole generate -// run. Given stable adventure/field iteration order, ids are deterministic, so -// regeneration produces identical output and no diff churn. -let abbrExpansionCounter = 0; - -/** Post-sanitize: turn <abbr title> into a focusable tooltip trigger whose - * expansion is exposed to assistive tech via an adjacent sr-only span referenced - * by aria-describedby (mirrors the <Abbr> React component). - * - data-title drives the CSS/portal visual tooltip on hover/focus. - * - aria-describedby + the sr-only span keep the visible token as the accessible - * name and add the expansion as a description (WCAG 2.5.3). aria-label is not - * used because it would replace the visible token (e.g. "PR") with the expansion. - * - tabindex makes the tooltip reachable by keyboard and touch. - * Runs after rehypeSanitize (which strips these props from its allowlist), so the - * inserted span is not re-sanitized. */ -function expandAbbr() { - return function (tree) { - function walk(node) { - const children = node.children; - if (!children) return; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - if (child.type === "element" && child.tagName === "abbr" && child.properties?.title) { - const text = String(child.properties.title); - const id = `abbr-exp-${++abbrExpansionCounter}`; - child.properties.dataTitle = text; - child.properties.tabIndex = 0; - child.properties.ariaDescribedBy = id; - delete child.properties.title; - children.splice(i + 1, 0, { - type: "element", - tagName: "span", - properties: { id, className: ["sr-only"] }, - children: [{ type: "text", value: text }], - }); - i++; // skip the span just inserted - } - walk(child); - } - } - walk(tree); - }; -} - -const mdProcessor = unified() - .use(remarkParse) - .use(remarkGfm) - .use(remarkRehype, { allowDangerousHtml: true }) - .use(rehypeRaw) - .use(rehypeSanitize, sanitizeSchema) - .use(expandAbbr) - .use(rehypeStringify); - -/** A URL is not publicly navigable when its host is loopback, mDNS, or a - * single-label name (no public TLD). remark-gfm autolinks bare URLs written in - * prose (e.g. "runs on http://localhost:8080/"), so without this guard a - * local dev address would become a clickable new-tab link on the deployed - * site, pointing at the visitor's own machine. */ -function isNonPublicUrl(href) { - try { - const host = new URL(href).hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets - if (host === "localhost" || host === "0.0.0.0" || host === "::1") return true; - if (/^127\./.test(host)) return true; // IPv4 loopback range - if (/^10\./.test(host)) return true; // private class A - if (/^192\.168\./.test(host)) return true; // private class C - if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true; // private class B (172.16-31) - if (host.endsWith(".local")) return true; // mDNS - if (!host.includes(".")) return true; // single-label host, no public TLD - return false; - } catch { - return false; - } -} - -/** Add target/rel and the shared "opens in a new tab" hint to http/https <a> - * tags. The hint is exposed as an accessible description via - * aria-describedby="new-tab-hint" (a single hidden node rendered once in - * Layout.tsx), not folded into each link's accessible name. The external link - * icon is rendered via CSS ::after on [target="_blank"] — no inline SVG. - * Non-public URLs (localhost, loopback) are unwrapped to plain text: they are - * not navigable on the deployed site and must not open a new tab. */ -function annotateExternalLinks(html) { - return html.replace( - /<a href="(https?:\/\/[^"]+)"([^>]*)>([\s\S]*?)<\/a>/gi, - (_, href, restAttrs, content) => { - if (isNonPublicUrl(href)) return content; - const attrs = restAttrs.includes("target=") - ? restAttrs - : ` target="_blank" rel="noopener noreferrer"${restAttrs}`; - const described = restAttrs.includes("aria-describedby=") - ? attrs - : `${attrs} aria-describedby="new-tab-hint"`; - return `<a href="${href}"${described}>${content}</a>`; - } - ); -} - -/** Convert markdown to full block HTML (preserves <p>, <ul>, <pre>, headings). - * <pre> elements get tabindex="0" and aria-label so they're keyboard-accessible - * as scrollable regions in the prerendered HTML (WCAG 2.1 SC 2.1.1). */ -async function mdToBlock(str) { - if (!str) return ""; - const result = await mdProcessor.process(str); - let html = String(result).trim(); - html = html.replace(/<pre>/g, '<pre tabindex="0" aria-label="Code block">'); - html = annotateExternalLinks(html); - return html; -} - -/** Convert markdown to inline HTML, stripping the outer <p> wrapper when the - * output is a single paragraph. Use for short prose rendered inside <span> or <li>. */ -async function mdToInline(str) { - if (!str) return ""; - const result = await mdProcessor.process(str); - let html = String(result).trim(); - // Strip wrapping <p>...</p> only when the entire output is exactly one paragraph. - const pCount = (html.match(/<p>/g) ?? []).length; - if (pCount === 1 && html.startsWith("<p>") && html.endsWith("</p>")) { - html = html.slice(3, -4); - } - html = annotateExternalLinks(html); - return html; -} - -/** Convert each item in a string array with mdToInline. */ -async function mdToInlineArray(arr) { - if (!arr || arr.length === 0) return []; - return Promise.all(arr.map(mdToInline)); -} - -/** Convert each item in a string array with mdToBlock. */ -async function mdToBlockArray(arr) { - if (!arr || arr.length === 0) return []; - return Promise.all(arr.map(mdToBlock)); -} - -// --- Helpers --- - -function toConstName(id) { - return id.toUpperCase().replace(/-/g, "_"); -} - -/** Strip common markdown syntax so strings are safe for plain-text meta descriptions. */ -function stripMarkdown(str) { - if (!str) return ""; - return str - .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") - .replace(/\*\*([^*]+)\*\*/g, "$1") - .replace(/\*([^*]+)\*/g, "$1") - .replace(/`([^`]+)`/g, "$1") - .trim(); -} - -/** Truncate at the last word boundary before `max` chars and append "...". */ -function truncate(str, max) { - if (str.length <= max) return str; - const cut = str.lastIndexOf(" ", max - 3); - return cut > max / 2 ? str.slice(0, cut) + "..." : str.slice(0, max); -} - -/** Synthesize a meta description for a challenge level from YAML fields. */ -function buildLevelMetaDescription(level) { - const { name, difficulty } = normalizeLevelFields(level); - const rawIntro = Array.isArray(level.intro) ? level.intro[0] : (level.summary || ""); - const intro = stripMarkdown(rawIntro); - const topics = (level.topics || []).join(", "); - const base = `${name}: ${intro}`; - const suffix = ` A ${difficulty.toLowerCase()} ${topics} challenge on ${BRAND_NAME}.`; - if (base.length + suffix.length <= 160) return base + suffix; - return truncate(base, 160); -} - -/** Synthesize a meta description for an adventure from YAML fields. */ -function buildAdventureMetaDescription(data) { - const { title } = normalizeAdventureFields(data); - if (data.overview && data.overview.length > 0) { - const clean = stripMarkdown(data.overview[0]); - return truncate(clean, 160); - } - const tags = (data.tags || []).slice(0, 3).join(", "); - return truncate(`${title}: a hands-on ${tags} adventure on ${BRAND_NAME}.`, 160); -} - -function fail(msg) { - console.error(`\x1b[31mError:\x1b[0m ${msg}`); - process.exit(1); -} - -function warn(msg) { - console.warn(`\x1b[33mWarning:\x1b[0m ${msg}`); -} - -/** - * Escape a string for safe embedding in a JS template literal (backtick-quoted). - * Handles backticks, ${}, and backslashes. - */ -function escapeTemplateLiteral(str) { - return str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); -} - -/** - * Escape a string for safe embedding in a JS double-quoted string. - */ -function escapeDoubleQuoted(str) { - return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); -} - -/** - * Determine whether a string needs template literal (contains COMMUNITY_URL or CODESPACES_BASE references, - * or has newlines/backticks that make template literals cleaner). - */ -function needsTemplateLiteral(str) { - return str.includes("\n") || str.includes("`"); -} - -/** - * Format a string value as a JS string literal or template literal. - */ -function formatString(str, indent = "") { - if (needsTemplateLiteral(str)) { - return `\`${escapeTemplateLiteral(str)}\``; - } - return `"${escapeDoubleQuoted(str)}"`; -} - -/** - * Format an array of strings. - */ -function formatStringArray(arr, indent) { - if (!arr || arr.length === 0) return "[]"; - if (arr.length === 1 && !needsTemplateLiteral(arr[0]) && arr[0].length < 80) { - return `["${escapeDoubleQuoted(arr[0])}"]`; - } - const items = arr.map((s) => `${indent} ${formatString(s)},`).join("\n"); - 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); -} - -/** 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() { - const entries = readdirSync(ADVENTURES_DIR, { withFileTypes: true }); - const yamls = []; - for (const entry of entries) { - if (entry.isDirectory()) { - const yamlPath = resolve(ADVENTURES_DIR, entry.name, "adventure.yaml"); - if (existsSync(yamlPath)) { - yamls.push({ id: entry.name, path: yamlPath }); - } - } - } - return yamls; -} - -// --- Validation --- - -const MONTH_NAME_TO_INDEX = { - JAN: 0, FEB: 1, MAR: 2, APR: 3, MAY: 4, JUN: 5, - JUL: 6, AUG: 7, SEP: 8, OCT: 9, NOV: 10, DEC: 11, -}; - -/** Parse a "MMM YYYY" month string (e.g. "MAY 2026") into a numeric sort key. */ -function monthToSortKey(month) { - if (typeof month !== "string") return 0; - const match = month.trim().toUpperCase().match(/^([A-Z]{3})\s+(\d{4})$/); - if (!match) return 0; - const m = MONTH_NAME_TO_INDEX[match[1]]; - if (m === undefined) return 0; - return Number(match[2]) * 12 + m; -} - -/** - * Fetch the set of valid devcontainer folder names from the challenges repo - * via the GitHub REST API using native fetch. Works without authentication for - * public repos, so it runs correctly in every environment — local, sync - * workflow, and validate CI — without requiring cross-repo token access. - * Returns null when the check cannot be performed so callers can skip it - * gracefully instead of failing the build. - */ -async function fetchValidDevcontainerFolders() { - try { - const response = await fetch( - "https://api.github.com/repos/off-on-dev/open-source-challenges/contents/.devcontainer", - { headers: { "User-Agent": "offon-dev/website generate-adventures" } } - ); - if (!response.ok) { - warn(`Could not fetch devcontainer folder list from GitHub (HTTP ${response.status}). Skipping devcontainer path validation.`); - return null; - } - const entries = await response.json(); - return new Set(entries.filter((e) => e.type === "dir").map((e) => e.name)); - } catch (e) { - warn(`Could not fetch devcontainer folder list from GitHub (${e.message}). Skipping devcontainer path validation.`); - return null; - } -} - -/** - * For each level whose devcontainer value is missing from validFolders, try - * to find the correct folder by matching both the adventure slug and the level - * difficulty (lowercased) against the known folder names. - * - * If exactly one candidate matches, the YAML file on disk is patched and the - * in-memory data object is updated. Ambiguous or unresolvable cases are left - * unchanged so validateAdventure can report them as errors. - * - * Only called in generate mode — CI (--validate-only) must not silently - * mutate source files. - * - * @returns {{ levelIndex: number, from: string, to: string }[]} - */ -function autoCorrectDevcontainerPaths(data, id, yamlPath, validFolders) { - if (!validFolders || !data.levels) return []; - const corrections = []; - let rawYaml = readFileSync(yamlPath, "utf-8"); - - for (let i = 0; i < data.levels.length; i++) { - const level = data.levels[i]; - const wrong = level.devcontainer; - if (!wrong || validFolders.has(wrong)) continue; - - const difficulty = (level.difficulty || LEVEL_DIFFICULTY_BY_EMOJI[level.emoji] || "").toLowerCase(); - const candidates = [...validFolders].filter( - (f) => f.includes(id) && (!difficulty || f.includes(difficulty)) - ); - - if (candidates.length !== 1) continue; - - const correct = candidates[0]; - rawYaml = rawYaml.replace(`devcontainer: ${wrong}`, `devcontainer: ${correct}`); - data.levels[i].devcontainer = correct; - corrections.push({ levelIndex: i, from: wrong, to: correct }); - } - - if (corrections.length > 0) writeFileSync(yamlPath, rawYaml); - return corrections; -} - -/** - * Convert an Ajv instancePath ("/levels/0/unknown_field") to the display - * format used by the custom validator ("levels[0].unknown_field"). - * Uses a split-reduce to handle adjacent numeric indices correctly - * (e.g. "/tools/1/2" → "tools[1][2]" rather than "tools[1]2"). - */ -function ajvPathToDisplay(instancePath) { - if (!instancePath) return ""; - return instancePath.slice(1).split("/").reduce((acc, seg) => - /^\d+$/.test(seg) ? `${acc}[${seg}]` : acc ? `${acc}.${seg}` : seg, ""); -} - -/** - * Run JSON Schema validation and return errors for structural issues not - * already covered by the custom validateAdventure() checks below. - * Skips keywords whose violations are reported with better messages by the - * custom validator (see SCHEMA_SKIP_KEYWORDS). "enum" is special: only - * difficulty enum errors are skipped (re-checked in validateLevel); any other - * enum field added to the schema in future will still produce AJV errors here. - */ -function schemaErrors(data) { - const valid = schemaValidate(data); - if (valid) return []; - return (schemaValidate.errors ?? []) - .filter((e) => { - if (SCHEMA_SKIP_KEYWORDS.has(e.keyword)) return false; - if (e.keyword === "enum" && e.instancePath.endsWith("/difficulty")) return false; - return true; - }) - .map((e) => { - const path = ajvPathToDisplay(e.instancePath) || "adventure"; - if (e.keyword === "additionalProperties") { - return `${path}: Unknown field "${e.params.additionalProperty}"`; - } - return `${path}: ${e.message}`; - }); -} - -function validateLevel(level, index, adventureId, validDevcontainerFolders) { - const errors = []; - const prefix = `levels[${index}]`; - - 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(`${adventureId} ${prefix}: deadline "${level.deadline}" is not ISO 8601 — update before publishing`); - } - - 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}"`); - } - - if (!level.topics || level.topics.length === 0) errors.push(`${prefix}: Missing topics`); - if (!level.learnings && !level.what_you_learn) errors.push(`${prefix}: Missing learnings (or what_you_learn)`); - - if (!level.devcontainer) { - errors.push(`${prefix}: Missing devcontainer`); - } else if (validDevcontainerFolders && !validDevcontainerFolders.has(level.devcontainer)) { - errors.push(`${prefix}: devcontainer "${level.devcontainer}" not found in off-on-dev/open-source-challenges/.devcontainer — check https://github.com/off-on-dev/open-source-challenges/tree/main/.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(`${adventureId} ${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`); - if (!level.verification) errors.push(`${prefix}: Missing verification`); - - return errors; -} - -function validateAdventure(data, id, validDevcontainerFolders) { - const errors = [...schemaErrors(data)]; - - 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.title && !data.name) errors.push("Missing required field: title (or name)"); - if (!data.month) errors.push("Missing required field: month"); - 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"); - } else { - data.levels.forEach((level, i) => errors.push(...validateLevel(level, i, id, validDevcontainerFolders))); - } - - return errors; -} - -// --- Code Generation --- - -async function generateLevelCode(level, adventureId, indent) { - const lines = []; - const i = indent; - const i2 = indent + " "; - - const { id: levelId, name: levelName, difficulty: levelDifficulty, learnings: levelLearnings, intro: levelIntro, discussionUrl: levelDiscussionUrl } = normalizeLevelFields(level); - - // Pre-render prose fields to HTML at build time. - const learningsHtml = await mdToInlineArray(levelLearnings); - const audienceHtml = level.audience ? await mdToInline(level.audience) : null; - const objectiveHtml = level.objective ? await mdToInlineArray(level.objective) : null; - const hookHtml = level.hook ? await mdToBlock(level.hook) : null; - // intro and backstory are rendered as individual <p> items in components, - // so use inline conversion (no <p> wrapper) to keep the JSX <p> container valid. - const introHtml = levelIntro ? await mdToInlineArray(levelIntro) : null; - const backstoryHtml = level.backstory ? await mdToInlineArray(level.backstory) : null; - const scenarioHtml = level.scenario ? await mdToBlock(level.scenario) : null; - const architectureHtml = level.architecture ? await mdToBlockArray(level.architecture) : null; - - lines.push(`${i}{`); - lines.push(`${i2}id: "${escapeDoubleQuoted(levelId)}",`); - lines.push(`${i2}name: "${escapeDoubleQuoted(levelName)}",`); - lines.push(`${i2}difficulty: "${levelDifficulty}",`); - - if (level.topics) { - lines.push(`${i2}topics: [${level.topics.map((t) => `"${escapeDoubleQuoted(t)}"`).join(", ")}],`); - } - if (audienceHtml) { - lines.push(`${i2}audience: ${formatString(audienceHtml)},`); - } - if (level.estimated_time) { - lines.push(`${i2}estimatedTime: ${formatString(level.estimated_time)},`); - } - - lines.push(`${i2}learnings: ${formatStringArray(learningsHtml, i2)},`); - - // Build codespacesUrl from devcontainer short name - const fullDevcontainerPath = `.devcontainer/${level.devcontainer}/devcontainer.json`; - const encodedPath = encodeURIComponent(fullDevcontainerPath).replace(/%2F/g, "%2F"); - const machineParam = level.codespaces_machine === "4core" ? "&machine=standardLinux32gb" : ""; - lines.push(`${i2}codespacesUrl: \`\${CODESPACES_BASE}?devcontainer_path=${encodedPath}&quickstart=1${machineParam}\`,`); - - // 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(parseDeadline(level.deadline))}",`); - if (hookHtml) lines.push(`${i2}hook: ${formatString(hookHtml)},`); - if (introHtml) lines.push(`${i2}intro: ${formatStringArray(introHtml, i2)},`); - if (backstoryHtml) lines.push(`${i2}backstory: ${formatStringArray(backstoryHtml, i2)},`); - if (objectiveHtml) lines.push(`${i2}objective: ${formatStringArray(objectiveHtml, i2)},`); - if (scenarioHtml) lines.push(`${i2}scenario: ${formatString(scenarioHtml)},`); - if (architectureHtml) lines.push(`${i2}architecture: ${formatStringArray(architectureHtml, i2)},`); - - if (level.architecture_diagram) { - const baseName = level.architecture_diagram.replace(/\.svg$/, ""); - const varName = baseName.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); - lines.push(`${i2}architectureDiagram: ${varName},`); - } - if (level.diagram_alt) lines.push(`${i2}diagramAlt: ${formatString(level.diagram_alt)},`); - if (level.architecture_ascii) lines.push(`${i2}architectureAscii: ${formatString(level.architecture_ascii)},`); - - if (level.toolbox && level.toolbox.length > 0) { - lines.push(`${i2}toolbox: [`); - for (const tool of level.toolbox) { - const descHtml = tool.description ? await mdToInline(tool.description) : ""; - const parts = [`name: "${escapeDoubleQuoted(tool.name)}"`, `description: ${formatString(descHtml)}`]; - if (tool.url) parts.push(`url: "${escapeDoubleQuoted(tool.url)}"`); - lines.push(`${i2} { ${parts.join(", ")} },`); - } - lines.push(`${i2}],`); - } - - const steps = level.how_to_play ? [...level.how_to_play] : []; - if (level.services && level.services.length > 0) { - const accessible = level.services.filter((s) => !s.internal); - const internal = level.services.filter((s) => s.internal); - if (accessible.length > 0) { - let body = "Open the **Ports** tab and navigate to each service:\n\n"; - for (const svc of accessible) { - const creds = svc.credentials ? ` (${svc.credentials})` : ""; - body += `- **Port ${String(svc.port)}:** ${svc.name}${creds}. ${svc.description}`; - body += "\n"; - } - if (internal.length > 0) { - body += "\n"; - for (const svc of internal) { - body += `${svc.name} runs on the docker-internal network only. No port forwarding needed.\n`; - } - } - steps.splice(1, 0, { title: "Explore the UIs", content: body.trim() }); - } - } - if (steps.length > 0) { - lines.push(`${i2}howToPlay: [`); - for (const step of steps) { - const titleHtml = step.title ? await mdToInline(step.title) : ""; - const contentHtml = await mdToBlock(step.content); - lines.push(`${i2} { title: ${formatString(titleHtml)}, content: ${formatString(contentHtml)} },`); - } - lines.push(`${i2}],`); - } - - if (level.helpful_links && level.helpful_links.length > 0) { - lines.push(`${i2}helpfulLinks: [`); - for (const link of level.helpful_links) { - const parts = [`title: "${escapeDoubleQuoted(link.title)}"`, `url: "${escapeDoubleQuoted(link.url)}"`]; - if (link.description) parts.push(`description: "${escapeDoubleQuoted(link.description)}"`); - lines.push(`${i2} { ${parts.join(", ")} },`); - } - lines.push(`${i2}],`); - } - - if (level.verification) { - lines.push(`${i2}verification: {`); - lines.push(`${i2} command: "${escapeDoubleQuoted(level.verification.command)}",`); - lines.push(`${i2} description: "${escapeDoubleQuoted(level.verification.description)}",`); - lines.push(`${i2}},`); - } - - const levelMetaDesc = level.meta_description || buildLevelMetaDescription(level); - lines.push(`${i2}metaDescription: ${formatString(levelMetaDesc)},`); - if (level.solved_count !== undefined) lines.push(`${i2}solvedCount: ${level.solved_count},`); - if (level.top_players && level.top_players.length > 0) { - lines.push(`${i2}topPlayers: [`); - for (const p of level.top_players) { - lines.push(`${i2} { username: "${escapeDoubleQuoted(p.username)}", count: ${p.count} },`); - } - lines.push(`${i2}],`); - } - - lines.push(`${i}}`); - return lines.join("\n"); -} - -async function generateAdventureTs(data) { - const lines = []; - const constName = toConstName(data.slug); - - // Imports - lines.push(`import { CODESPACES_BASE, COMMUNITY_URL } from "@/data/constants";`); - - // Collect diagram imports — use a camelCase variable name derived from the filename - const diagrams = new Map(); - for (const level of data.levels) { - if (level.architecture_diagram) { - const baseName = level.architecture_diagram.replace(/\.svg$/, ""); - const varName = baseName.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); - if (!diagrams.has(baseName)) { - diagrams.set(baseName, { varName, file: level.architecture_diagram }); - } - } - } - for (const [, d] of diagrams) { - lines.push(`import ${d.varName} from "@/assets/diagrams/${d.file}";`); - } - - lines.push(`import type { Adventure } from "./types";`); - lines.push(``); - 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/components/AdventureIcon.tsx`); - } - if (data.icon && !VALID_ICONS.has(data.icon)) { - warn(`${data.slug}: icon "${data.icon}" is not in VALID_ICONS — add it to scripts/generate-adventures.mjs VALID_ICONS and src/components/AdventureIcon.tsx ICONS`); - } - - // Pre-render prose fields to HTML at build time. - const storyHtml = await mdToInline(adventureStory); - const contributorAboutHtml = data.contributor?.about ? await mdToInline(data.contributor.about) : null; - // Each item renders via InlineProse; mdToInline strips the outer <p> on single-paragraph - // items so they can be safely wrapped as <p md-inline> at render time. - const backstoryHtml = data.backstory ? await mdToInlineArray(data.backstory) : null; - - lines.push(`export const ${constName}: Adventure = {`); - lines.push(` id: "${data.slug}",`); - lines.push(` title: "${escapeDoubleQuoted(adventureTitle)}",`); - if (adventureIcon) lines.push(` icon: "${escapeDoubleQuoted(adventureIcon)}",`); - lines.push(` month: "${data.month}",`); - lines.push(` story: ${formatString(storyHtml)},`); - const adventureMetaDesc = data.meta_description || buildAdventureMetaDescription(data); - lines.push(` metaDescription: ${formatString(adventureMetaDesc)},`); - lines.push(` tags: [${data.tags.map((t) => `"${escapeDoubleQuoted(t)}"`).join(", ")}],`); - - if (data.contributor) { - lines.push(` contributor: {`); - lines.push(` name: "${escapeDoubleQuoted(data.contributor.name)}",`); - if (data.contributor.url) lines.push(` url: "${escapeDoubleQuoted(data.contributor.url)}",`); - if (contributorAboutHtml) lines.push(` aboutHtml: ${formatString(contributorAboutHtml)},`); - lines.push(` },`); - } - - if (backstoryHtml) { - lines.push(` backstory: ${formatStringArray(backstoryHtml, " ")},`); - } - - if (data.overview) { - lines.push(` overview: ${formatStringArray(data.overview, " ")},`); - } - - if (data.rewards) { - const eligibilityRaw = data.rewards.eligibility ?? DEFAULT_REWARDS_ELIGIBILITY; - const rankingNoteRaw = data.rewards.ranking_note ?? DEFAULT_REWARDS_RANKING_NOTE; - const rankingRulesUrl = data.rewards.ranking_rules_url ?? DEFAULT_REWARDS_RANKING_RULES_PATH; - const eligibilityHtml = await mdToInline(eligibilityRaw); - const rankingNoteHtml = await mdToInline(rankingNoteRaw); - // rankingNote renders inside a <span> inside a <p>, so block-level HTML there - // is always invalid HTML. Fail the build rather than silently corrupt the DOM. - if (/<(p|ul|ol|blockquote|h[1-6]|pre|table|hr|figure|div)\b/.test(rankingNoteHtml)) { - fail( - `${data.slug}: rewards.ranking_note produces block-level HTML. ` + - `It must be a single inline paragraph — collapse it to one line and regenerate.` - ); - } - lines.push(` rewards: {`); - const rewardsDeadline = data.rewards.deadline === "TODO" ? "" : parseDeadline(data.rewards.deadline); - lines.push(` deadline: "${escapeDoubleQuoted(rewardsDeadline)}",`); - lines.push(` eligibility: ${formatString(eligibilityHtml)},`); - lines.push(` tiers: [`); - for (const tier of data.rewards.tiers) { - const tierDescHtml = await mdToInline(tier.description); - lines.push(` { label: "${escapeDoubleQuoted(tier.label)}", description: ${formatString(tierDescHtml)} },`); - } - lines.push(` ],`); - lines.push(` rankingNote: ${formatString(rankingNoteHtml)},`); - if (rankingRulesUrl.startsWith("http")) { - lines.push(` rankingRulesUrl: "${escapeDoubleQuoted(rankingRulesUrl)}",`); - } else { - const path = rankingRulesUrl.startsWith("/") ? rankingRulesUrl : `/${rankingRulesUrl}`; - lines.push(` rankingRulesUrl: \`\${COMMUNITY_URL}${path}\`,`); - } - lines.push(` },`); - } - - if (data.upcoming_levels && data.upcoming_levels.length > 0) { - lines.push(` upcomingLevels: [`); - for (const ul of data.upcoming_levels) { - lines.push(` { name: "${escapeDoubleQuoted(ul.name)}", difficulty: "${ul.difficulty}" },`); - } - lines.push(` ],`); - } - - lines.push(` levels: [`); - for (const level of data.levels) { - lines.push((await generateLevelCode(level, data.id, " ")) + ","); - } - lines.push(` ],`); - lines.push(`};`); - lines.push(``); - - 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. - */ -/** - * Returns lines for the ADVENTURE_CONTRIBUTORS export. - * sourceName is the array to derive from; sourceType is the element type. - * The caller is responsible for pushing any preceding JSDoc comment. - */ -function buildContributorsCode(sourceName, sourceType) { - return [ - `export const ADVENTURE_CONTRIBUTORS: AdventureContributor[] = Object.values(`, - ` ${sourceName}`, - ` .filter((a): a is ${sourceType} & { contributor: NonNullable<${sourceType}["contributor"]> } => a.contributor !== undefined)`, - ` .reduce<Record<string, AdventureContributor>>((acc, a) => {`, - ` const key = a.contributor.name;`, - ` if (!acc[key]) {`, - ` acc[key] = { name: a.contributor.name, url: a.contributor.url, aboutHtml: a.contributor.aboutHtml, adventures: [] };`, - ` }`, - ` acc[key].adventures.push({ id: a.id, title: a.title });`, - ` return acc;`, - ` }, {})`, - `);`, - ]; -} - -async function generateSummariesTs(adventures) { - const lines = []; - lines.push(`// Generated by scripts/generate-adventures.mjs — do not edit by hand.`); - lines.push(`import type { AdventureCardSummary, AdventureContributor, RelatedLevelSummary } from "./types";`); - lines.push(``); - lines.push(`export const ADVENTURE_SUMMARIES: AdventureCardSummary[] = [`); - - const now = new Date(); - // Cache processed contributor bios by contributor name so that adventures sharing - // the same contributor always produce identical aboutHtml (same abbr-exp-* IDs). - const contributorAboutHtmlCache = new Map(); - - for (const data of adventures) { - lines.push(` {`); - const { title: summaryTitle, story: summaryStory, icon: summaryIcon } = normalizeAdventureFields(data); - lines.push(` id: "${data.slug}",`); - lines.push(` title: "${escapeDoubleQuoted(summaryTitle)}",`); - lines.push(` month: "${data.month}",`); - if (/[*_`]/.test(summaryStory)) { - warn(`${data.slug}: story contains markdown syntax (*_\`). AdventureCard renders story as plain text — format the story as plain prose or it will display unstyled in card views.`); - } - // story in summaries stays as plain text — AdventureCard renders it without markdown. - lines.push(` story: ${formatString(summaryStory)},`); - lines.push(` tags: [${data.tags.map((t) => `"${escapeDoubleQuoted(t)}"`).join(", ")}],`); - if (data.contributor) { - const cacheKey = data.contributor.url ?? data.contributor.name; - if (data.contributor.about && !contributorAboutHtmlCache.has(cacheKey)) { - contributorAboutHtmlCache.set(cacheKey, await mdToInline(data.contributor.about)); - } - const contributorAboutHtml = contributorAboutHtmlCache.get(cacheKey) ?? null; - lines.push(` contributor: {`); - lines.push(` name: "${escapeDoubleQuoted(data.contributor.name)}",`); - if (data.contributor.url) lines.push(` url: "${escapeDoubleQuoted(data.contributor.url)}",`); - if (contributorAboutHtml) lines.push(` aboutHtml: ${formatString(contributorAboutHtml)},`); - lines.push(` },`); - } - const rewardsLive = data.rewards?.deadline && new Date(parseDeadline(data.rewards.deadline)) > now; - const levelLive = !rewardsLive && (data.levels ?? []).some((l) => l.deadline && new Date(parseDeadline(l.deadline)) > now); - if (rewardsLive || levelLive) { - lines.push(` isLive: true,`); - } - if (summaryIcon) lines.push(` icon: "${escapeDoubleQuoted(summaryIcon)}",`); - lines.push(` levels: [`); - for (const level of data.levels) { - lines.push(` {`); - const { id: summaryId, name: summaryName, difficulty: summaryDifficulty, learnings: summaryLearnings } = normalizeLevelFields(level); - // Pre-render learnings to inline HTML for FilteredLevelCard. - const summaryLearningsHtml = await mdToInlineArray(summaryLearnings); - 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(", ")}],`); - } - lines.push(` learnings: ${formatStringArray(summaryLearningsHtml, " ")},`); - if (level.estimated_time) { - lines.push(` estimatedTime: ${formatString(level.estimated_time)},`); - } - 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(` ...(a.isLive ? { isLive: true } : {}),`); - lines.push(` ...(a.icon ? { adventureIcon: a.icon } : {}),`); - lines.push(` }))`); - lines.push(` );`); - lines.push(``); - lines.push(`/**`); - lines.push(` * Community members who contributed an adventure, grouped by person.`); - lines.push(` * Derived from ADVENTURE_SUMMARIES — import from here instead of "@/data/adventures"`); - lines.push(` * on pages that do not otherwise need the full adventure dataset (About, Adventures, Challenges).`); - lines.push(` */`); - lines.push(...buildContributorsCode("ADVENTURE_SUMMARIES", "AdventureCardSummary")); - lines.push(``); - - return lines.join("\n"); -} - -function generateIndexTs(adventures) { - const lines = []; - - // Imports for each adventure - for (const adv of adventures) { - const constName = toConstName(adv.slug); - lines.push(`import { ${constName} } from "./${adv.slug}.generated";`); - } - lines.push(`import type { Adventure, AdventureContributor, RelatedLevel } from "./types";`); - lines.push(``); - 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) { - lines.push(` ${toConstName(adv.slug)},`); - } - lines.push(`];`); - lines.push(``); - lines.push(`/** All unique technology tags across all adventures, sorted alphabetically. Shared with filter components; do not re-derive in component files. */`); - lines.push(`export const ALL_TAGS: string[] = Array.from(`); - lines.push(` new Set(ADVENTURES.flatMap((a) => a.tags))`); - lines.push(`).sort();`); - lines.push(``); - lines.push(`/** Community members who contributed an adventure, grouped by person. Derived from ADVENTURES; do not re-derive in components. */`); - lines.push(...buildContributorsCode("ADVENTURES", "Adventure")); - lines.push(``); - lines.push(`/** Returns all levels across all adventures that include the given technology tag. */`); - lines.push(`export const getLevelsByTag = (tag: string): RelatedLevel[] =>`); - lines.push(` ADVENTURES.filter((adventure) => adventure.tags.includes(tag)).flatMap((adventure) =>`); - lines.push(` adventure.levels.map((level) => ({`); - lines.push(` level,`); - lines.push(` adventureId: adventure.id,`); - lines.push(` adventureTitle: adventure.title,`); - lines.push(` }))`); - lines.push(` );`); - lines.push(``); - lines.push(`export { tagToSlug, slugToTag } from "./tag-utils";`); - - 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 + "/", "")}`); - } -} - -// Parse lastmod dates already committed in the sitemap, keyed by URL. -// Used as a stable fallback so re-running the generator on a clean file never changes -// existing dates (avoids churn from shallow-clone environments where git log may be empty). -const SITEMAP_PATH = resolve(ROOT, "public/sitemap.xml"); - -function loadExistingSitemapDates() { - const dates = new Map(); - try { - const content = readFileSync(SITEMAP_PATH, "utf-8"); - for (const m of content.matchAll(/<loc>([^<]+)<\/loc><lastmod>([^<]+)<\/lastmod>/g)) { - dates.set(m[1], m[2]); - } - } catch { /* sitemap may not exist yet on first run */ } - return dates; -} - -const _existingSitemapDates = loadExistingSitemapDates(); - -// Returns the date the adventure.yaml was last modified, for use as sitemap lastmod. -// If the YAML has uncommitted changes, uses today. If the URL is already in the sitemap -// and the YAML is clean, preserves the existing date. For new URLs, falls back to the -// git commit date (or today if git history is unavailable, e.g. shallow clones). -// Results are memoized per slug. -const _lastmodCache = new Map(); -function getAdventureLastmod(slug) { - if (_lastmodCache.has(slug)) return _lastmodCache.get(slug); - const relPath = `src/data/adventures/${slug}/adventure.yaml`; - const status = spawnSync("git", ["status", "--porcelain", "--", relPath], { - cwd: ROOT, encoding: "utf-8", - }); - if (status.error || status.stdout.trim()) { - // YAML has uncommitted changes — content is actively being updated, use today. - _lastmodCache.set(slug, TODAY); - return TODAY; - } - // YAML is clean. If this adventure already exists in the sitemap, keep its date - // to avoid churn caused by git log returning different results across environments - // (e.g. shallow clones in CI vs full history locally). - const existingDate = _existingSitemapDates.get(`https://offon.dev/adventures/${slug}/`); - if (existingDate) { - _lastmodCache.set(slug, existingDate); - return existingDate; - } - // New adventure not yet in the sitemap — use git log date or today as fallback. - const gitLog = spawnSync("git", ["log", "--format=%ci", "-1", "--", relPath], { - cwd: ROOT, encoding: "utf-8", - }); - const gitDate = gitLog.stdout.trim(); - const result = gitDate ? gitDate.slice(0, 10) : TODAY; - _lastmodCache.set(slug, result); - return result; -} - -/** 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) { - const lastmod = getAdventureLastmod(a.slug); - lines.push(` <url><loc>https://offon.dev/adventures/${a.slug}/</loc><lastmod>${lastmod}</lastmod><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><lastmod>${lastmod}</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>`); - const solutionFile = resolve(ROOT, `src/data/solutions/${a.slug}/${l.level}.ts`); - if (existsSync(solutionFile)) { - lines.push(` <url><loc>https://offon.dev/adventures/${a.slug}/levels/${l.level}/solution/</loc><lastmod>${lastmod}</lastmod><changefreq>monthly</changefreq><priority>0.7</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 "; -} - -function buildLlmsTxtBody(adventures) { - const lines = []; - for (const a of adventures) { - const { title, story } = normalizeAdventureFields(a); - lines.push(`- [${title}](https://offon.dev/adventures/${a.slug}/): ${story}`); - for (const l of a.levels) { - const solutionFile = resolve(ROOT, `src/data/solutions/${a.slug}/${l.level}.ts`); - if (existsSync(solutionFile)) { - lines.push(` - [${l.name} solution](https://offon.dev/adventures/${a.slug}/levels/${l.level}/solution/)`); - } - } - } - return "\n" + lines.join("\n") + "\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, adventures) { - const lines = tags.map((t) => { - const url = `https://offon.dev/challenges/${tagToSlug(t)}/`; - const existing = _existingSitemapDates.get(url); - const matching = adventures.filter((a) => (a.tags || []).includes(t)); - // Compute what the date would be from adventure data. - const derived = matching.length > 0 - ? matching.map((a) => getAdventureLastmod(a.slug)).sort().at(-1) - : TODAY; - // ISO date strings (YYYY-MM-DD) sort lexicographically in chronological order, - // so string comparison is equivalent to date comparison here. - // Preserve existing date if derived is not newer — prevents churn in shallow-clone - // environments where adventure dates may differ across runs. - const lastmod = existing && derived <= existing ? existing : derived; - return ` <url><loc>${url}</loc><lastmod>${lastmod}</lastmod><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 XML comment markers so the anchors remain stable when lastmod - // dates are updated. The comments must not be removed or renamed. - replaceRegion( - resolve(ROOT, "public/sitemap.xml"), - `<!-- GENERATED:adventures -->`, - `<!-- /GENERATED:adventures -->`, - 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) - ); - replaceRegion( - resolve(ROOT, "public/llms.txt"), - "Each adventure is a scenario-driven challenge with beginner, intermediate, and expert levels.", - "## Challenge Technologies", - buildLlmsTxtBody(adventures) - ); - - // Challenge tag URLs use the same XML comment pattern as adventures above. - const tags = collectAllTags(adventures); - replaceRegion( - resolve(ROOT, "public/sitemap.xml"), - `<!-- GENERATED:challenge-tags -->`, - `<!-- /GENERATED:challenge-tags -->`, - buildSitemapTagsBody(tags, adventures) - ); - 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 --- - -async function main() { - const yamls = findAdventureYamls(); - - if (yamls.length === 0) { - warn("No adventure.yaml files found. Nothing to generate."); - return; - } - - console.log(`Found ${yamls.length} adventure YAML file(s):\n`); - - const validDevcontainerFolders = await fetchValidDevcontainerFolders(); - const adventures = []; - let hasErrors = false; - - for (const { id, path } of yamls) { - const raw = readFileSync(path, "utf-8"); - let data; - try { - data = parseYaml(raw); - } catch (e) { - console.error(` \x1b[31m✗\x1b[0m ${id}/adventure.yaml: YAML parse error: ${e.message}`); - hasErrors = true; - continue; - } - - // In generate mode, auto-correct devcontainer values that don't match - // the challenges repo before validation so the corrected values pass. - // --validate-only intentionally skips this to keep CI a pure read-only check. - if (!validateOnly && validDevcontainerFolders) { - const corrections = autoCorrectDevcontainerPaths(data, id, path, validDevcontainerFolders); - for (const { levelIndex, from, to } of corrections) { - warn(`${id} levels[${levelIndex}]: devcontainer auto-corrected "${from}" → "${to}" — update adventure.yaml in the challenges repo`); - } - } - - const errors = validateAdventure(data, id, validDevcontainerFolders); - if (errors.length > 0) { - console.error(` \x1b[31m✗\x1b[0m ${id}/adventure.yaml:`); - for (const err of errors) { - console.error(` - ${err}`); - } - hasErrors = true; - continue; - } - - console.log(` \x1b[32m✓\x1b[0m ${id}/adventure.yaml`); - adventures.push(data); - } - - if (hasErrors) { - fail("Validation failed. Fix the errors above before generating."); - } - - // Order adventures newest first, by month. Stable secondary by slug for ties. - adventures.sort((a, b) => { - const da = monthToSortKey(a.month); - const db = monthToSortKey(b.month); - if (db !== da) return db - da; - return a.slug.localeCompare(b.slug); - }); - - if (validateOnly) { - console.log("\n\x1b[32mAll YAML files are valid.\x1b[0m"); - return; - } - - // Generate .generated.ts files - for (const data of adventures) { - const tsContent = await generateAdventureTs(data); - const outPath = resolve(ADVENTURES_DIR, `${data.slug}.generated.ts`); - writeFileSync(outPath, tsContent); - console.log(` Generated: src/data/adventures/${data.slug}.generated.ts`); - } - - // Generate index.ts - const indexContent = generateIndexTs(adventures); - const indexPath = resolve(ADVENTURES_DIR, "index.ts"); - writeFileSync(indexPath, indexContent); - console.log(` Generated: src/data/adventures/index.ts`); - - // Generate summaries.ts (lightweight card-only data, no imports from full generated files) - const summariesContent = await generateSummariesTs(adventures); - const summariesPath = resolve(ADVENTURES_DIR, "summaries.ts"); - 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`); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/scripts/generate-solutions.mjs b/scripts/generate-solutions.mjs deleted file mode 100644 index 048440f8a..000000000 --- a/scripts/generate-solutions.mjs +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/env node - -/** - * Rebuild src/data/solutions/index.ts, manifest.ts, and patch all GENERATED:solutions regions. - * - * Usage: - * node scripts/generate-solutions.mjs - * node scripts/generate-solutions.mjs --validate-only - * - * Scans src/data/solutions/<adventure-id>/<level-id>.ts (authored files, not generated) - * and rebuilds six files: index.ts and manifest.ts (fully regenerated), plus - * patches GENERATED:solutions regions in react-router.config.ts, - * e2e/smoke.spec.ts, src/test/seo.test.ts, and src/test/prerender.test.ts. - * - * Never edit src/data/solutions/index.ts by hand. - */ - -import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { resolve, dirname, basename } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, ".."); -const SOLUTIONS_DIR = resolve(ROOT, "src/data/solutions"); -const VALIDATE_ONLY = process.argv.includes("--validate-only"); - -const EXCLUDED = new Set(["index.ts", "manifest.ts", "types.ts"]); - -function generateSolutions() { - if (!existsSync(SOLUTIONS_DIR)) { - console.error(`Solutions directory not found: ${SOLUTIONS_DIR}`); - process.exit(1); - } - - const entries = []; - - const adventureDirs = readdirSync(SOLUTIONS_DIR, { withFileTypes: true }) - .filter((d) => d.isDirectory()) - .map((d) => d.name); - - for (const adventureId of adventureDirs) { - const adventureDir = resolve(SOLUTIONS_DIR, adventureId); - const tsFiles = readdirSync(adventureDir).filter( - (f) => f.endsWith(".ts") && !f.endsWith(".generated.ts") && !EXCLUDED.has(f) - ); - - for (const tsFile of tsFiles) { - const levelId = basename(tsFile, ".ts"); - entries.push({ adventureId, levelId }); - } - } - - // Generate index.ts - const indexLines = [ - `// This file is auto-generated by scripts/generate-solutions.mjs. Do not edit by hand.`, - `import type { Solution } from "./types";`, - ``, - ]; - - for (const { adventureId, levelId } of entries) { - const importId = toIdentifier(adventureId, levelId); - indexLines.push(`import { solution as ${importId} } from "./${adventureId}/${levelId}";`); - } - - indexLines.push(``); - indexLines.push(`export const SOLUTIONS: Solution[] = [`); - for (const { adventureId, levelId } of entries) { - indexLines.push(` ${toIdentifier(adventureId, levelId)},`); - } - indexLines.push(`];`); - indexLines.push(``); - - const indexContent = indexLines.join("\n"); - const indexPath = resolve(SOLUTIONS_DIR, "index.ts"); - - if (VALIDATE_ONLY) { - const current = existsSync(indexPath) ? readFileSync(indexPath, "utf-8") : ""; - if (current !== indexContent) { - console.error(` Out of sync: src/data/solutions/index.ts`); - process.exit(1); - } - } else { - writeFileSync(indexPath, indexContent, "utf-8"); - console.log(` Generated solutions/index.ts (${entries.length} solution${entries.length !== 1 ? "s" : ""})`); - } - - // Generate manifest.ts — a lightweight set of solution IDs with no imports of full solution data. - // ChallengeDetail imports this instead of the full SOLUTIONS barrel so that solution text strings - // are not bundled into the challenge-detail route chunk. - const manifestLines = [ - `// This file is auto-generated by scripts/generate-solutions.mjs. Do not edit by hand.`, - ``, - `export const SOLUTION_IDS: Set<string> = new Set([`, - ]; - for (const { adventureId, levelId } of entries) { - manifestLines.push(` "${adventureId}/${levelId}",`); - } - manifestLines.push(`]);`); - manifestLines.push(``); - - const manifestContent = manifestLines.join("\n"); - const manifestPath = resolve(SOLUTIONS_DIR, "manifest.ts"); - - if (VALIDATE_ONLY) { - const currentManifest = existsSync(manifestPath) ? readFileSync(manifestPath, "utf-8") : ""; - if (currentManifest !== manifestContent) { - console.error(` Out of sync: src/data/solutions/manifest.ts`); - process.exit(1); - } - } else { - writeFileSync(manifestPath, manifestContent, "utf-8"); - console.log(` Generated solutions/manifest.ts (${entries.length} ID${entries.length !== 1 ? "s" : ""})`); - } - - // Patch GENERATED:solutions regions in route/config/test files. - patchRegion( - resolve(ROOT, "react-router.config.ts"), - "// GENERATED:solutions", - "// /GENERATED:solutions", - entries - .map(({ adventureId, levelId }) => ` "/adventures/${adventureId}/levels/${levelId}/solution",`) - .join("\n") + "\n " - ); - - patchRegion( - resolve(ROOT, "src/test/seo.test.ts"), - "// GENERATED:solutions", - "// /GENERATED:solutions", - entries - .map(({ adventureId, levelId }) => ` "/adventures/${adventureId}/levels/${levelId}/solution",`) - .join("\n") + "\n " - ); - - patchRegion( - resolve(ROOT, "src/test/prerender.test.ts"), - "// GENERATED:solutions", - "// /GENERATED:solutions", - entries - .map(({ adventureId, levelId }) => - [ - ` {`, - ` file: "adventures/${adventureId}/levels/${levelId}/solution/index.html",`, - ` check: { type: "contains", value: "Solution" },`, - ` },`, - ].join("\n") - ) - .join("\n") + "\n " - ); - - patchRegion( - resolve(ROOT, "e2e/smoke.spec.ts"), - "// GENERATED:solutions", - "// /GENERATED:solutions", - entries - .map(({ adventureId, levelId }) => - ` { path: "/adventures/${adventureId}/levels/${levelId}/solution", title: /Solution/ },` - ) - .join("\n") + "\n " - ); - - if (VALIDATE_ONLY) { - console.log(` Validated ${entries.length} solution${entries.length !== 1 ? "s" : ""} — all in sync`); - } -} - -function escapeRegex(s) { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function patchRegion(filePath, openMarker, closeMarker, body) { - if (!existsSync(filePath)) return; - const content = readFileSync(filePath, "utf-8"); - const re = new RegExp( - `(${escapeRegex(openMarker)})[\\s\\S]*?(${escapeRegex(closeMarker)})` - ); - if (!re.test(content)) { - console.error(` Error: region markers not found in ${filePath.replace(ROOT + "/", "")}`); - process.exit(1); - } - const next = content.replace(re, `$1\n${body}$2`); - if (next !== content) { - if (VALIDATE_ONLY) { - console.error(` Out of sync: ${filePath.replace(ROOT + "/", "")}`); - process.exit(1); - } - writeFileSync(filePath, next, "utf-8"); - console.log(` Patched region: ${filePath.replace(ROOT + "/", "")}`); - } -} - -function toIdentifier(adventureId, levelId) { - return `solution_${adventureId}_${levelId}`.replace(/-/g, "_"); -} - -generateSolutions(); diff --git a/scripts/lib/deadline.mjs b/scripts/lib/deadline.mjs deleted file mode 100644 index 83af4c94f..000000000 --- a/scripts/lib/deadline.mjs +++ /dev/null @@ -1,53 +0,0 @@ -// Converts "D Month YYYY at HH:MM TZ" (challenges repo format) to ISO 8601; pass-throughs for ISO, TODO, null, undefined. - -const MONTH_INDEX = { - January: 1, February: 2, March: 3, April: 4, May: 5, June: 6, - July: 7, August: 8, September: 9, October: 10, November: 11, December: 12, -}; - -// Offset strings for common timezone abbreviations used in adventure deadlines. -const TZ_OFFSETS = { - CET: "+01:00", - CEST: "+02:00", - UTC: "+00:00", - GMT: "+00:00", -}; - -export function parseDeadline(value) { - if (value === null || value === undefined) return value; - if (typeof value !== "string") { - throw new Error( - `[deadline] Expected a string but got ${typeof value} (${String(value)}). ` + - "The YAML parser may have auto-cast a timestamp — quote deadline values in YAML to prevent this." - ); - } - const trimmed = value.trim(); - if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) return trimmed; - if (trimmed === "TODO") return trimmed; - - // Expected: "[Weekday, ]D Month YYYY at HH:MM TZ" — anchored so partial matches don't slip through. - const match = trimmed.match( - /^(?:[A-Za-z]+,\s+)?(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})\s+at\s+(\d{2}):(\d{2})\s+([A-Z]+)$/ - ); - if (!match) { - console.warn(` [deadline] Unrecognised format — leaving as-is: "${value}"`); - return value; - } - - const [, dayStr, monthName, year, hours, minutes, tzAbbr] = match; - const month = MONTH_INDEX[monthName]; - const offset = TZ_OFFSETS[tzAbbr]; - - if (!month) { - console.warn(` [deadline] Unknown month "${monthName}" in "${value}" — leaving as-is`); - return value; - } - if (!offset) { - console.warn(` [deadline] Unknown timezone "${tzAbbr}" in "${value}" — leaving as-is`); - return value; - } - - const dd = dayStr.padStart(2, "0"); - const mm = String(month).padStart(2, "0"); - return `${year}-${mm}-${dd}T${hours}:${minutes}:00${offset}`; -} diff --git a/scripts/lib/level-sync.mjs b/scripts/lib/level-sync.mjs index b86c48c32..5095bdeef 100644 --- a/scripts/lib/level-sync.mjs +++ b/scripts/lib/level-sync.mjs @@ -1,7 +1,7 @@ // Pure helpers for sync-adventure.mjs. Extracted so level selection and the // "Coming Soon" computation can be unit-tested without mocking GitHub fetches. -import { LEVEL_DIFFICULTY_BY_ID, LEVEL_DIFFICULTY_BY_EMOJI, LEVEL_ORDER } from "./level-constants.mjs"; +import { LEVEL_DIFFICULTY_BY_ID, LEVEL_DIFFICULTY_BY_EMOJI, LEVEL_ORDER } from "../../src/lib/level-constants.mjs"; function asSet(value) { return value instanceof Set ? value : new Set(value); diff --git a/scripts/refresh-community-leaders.mjs b/scripts/refresh-community-leaders.mjs index 62319235e..33b1a0692 100644 --- a/scripts/refresh-community-leaders.mjs +++ b/scripts/refresh-community-leaders.mjs @@ -28,9 +28,10 @@ * NOTE: community.offon.dev is the actual Discourse server URL. */ -import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { readFileSync, existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { atomicWrite, fetchWithRetry } from "./discourse-utils.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, ".."); @@ -79,12 +80,12 @@ function loadDotEnv() { // Build avatar URL from the uploaded_avatar_id returned by queries 7 and 8. // If no ID is present, falls back to the Discourse CDN letter avatar. -function buildAvatarUrl(username, uploadedAvatarId, size = AVATAR_SIZE) { - if (!uploadedAvatarId) { - const letter = username.charAt(0).toLowerCase(); - return `https://avatars.discourse-cdn.com/v4/letter/${letter}/b5a626/${size}.png`; - } - return `${COMMUNITY_BASE}/user_avatar/community.offon.dev/${encodeURIComponent(username)}/${size}/${uploadedAvatarId}_2.png`; +// Always returns an https:// URL or undefined if construction fails. +export function buildAvatarUrl(username, uploadedAvatarId, size = AVATAR_SIZE) { + const url = !uploadedAvatarId + ? `https://avatars.discourse-cdn.com/v4/letter/${username.charAt(0).toLowerCase()}/b5a626/${size}.png` + : `${COMMUNITY_BASE}/user_avatar/community.offon.dev/${encodeURIComponent(username)}/${size}/${uploadedAvatarId}_2.png`; + return url.startsWith("https://") ? url : undefined; } async function runQuery(queryId, params, apiKey, apiUsername) { @@ -94,7 +95,7 @@ async function runQuery(queryId, params, apiKey, apiUsername) { body.append(`params[${k}]`, String(v)); } - const res = await fetch(url, { + const res = await fetchWithRetry(url, { method: "POST", headers: { "Api-Key": apiKey, @@ -246,14 +247,16 @@ async function main() { const existing = existsSync(OUT_PATH) ? readFileSync(OUT_PATH, "utf-8") : ""; const existingParsed = existing ? JSON.parse(existing) : {}; if (JSON.stringify(existingParsed.sections) !== JSON.stringify(sections)) { - writeFileSync(OUT_PATH, payload); + atomicWrite(OUT_PATH, payload); console.log(" Updated community-leaders.json"); } else { console.log(" No change to community-leaders.json"); } } -main().catch((err) => { - console.error(` Error: ${err.message}`); - process.exit(1); -}); +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((err) => { + console.error(` Error: ${err.message}`); + process.exit(1); + }); +} diff --git a/scripts/refresh-discussions.mjs b/scripts/refresh-discussions.mjs index ff453df51..9feb9dcfc 100644 --- a/scripts/refresh-discussions.mjs +++ b/scripts/refresh-discussions.mjs @@ -5,27 +5,33 @@ * from the Discourse API, and writes back `discussionPosts` and `totalReplies`. * Only writes if data changed. JSON files contain only discussion data. * + * No credentials required — uses the public Discourse topic API. + * * Usage: node scripts/refresh-discussions.mjs */ -import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"; +import { readFileSync, readdirSync, statSync } from "node:fs"; import { join, resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { atomicWrite, fetchWithRetry } from "./discourse-utils.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const COMMUNITY_BASE = "https://community.offon.dev"; const ADVENTURES_DIR = resolve(__dirname, "../src/data/adventures"); /** - * Resolves a Discourse avatar_template to a full URL. - * Templates can be relative paths (/user_avatar/...) or full URLs (https://...). + * Resolves a Discourse avatar_template to a full HTTPS URL. + * Returns undefined for http:// URLs (non-HTTPS) and unrecognised forms. + * Exported for unit-testing. */ -function resolveAvatarUrl(template, size = "40") { +export function resolveAvatarUrl(template, size = "40") { if (!template) return undefined; const resolved = template.replace("{size}", size); - if (resolved.startsWith("http")) return resolved; - return `${COMMUNITY_BASE}${resolved}`; + if (resolved.startsWith("https://")) return resolved; + if (resolved.startsWith("/")) return `${COMMUNITY_BASE}${resolved}`; + return undefined; } + /** * Extracts user-written plain text from a Discourse "cooked" HTML post. * Removes onebox embeds, images, URLs, and metadata. @@ -101,13 +107,20 @@ function findLevelFiles(dir) { async function fetchTopicPosts(topicId, topicUrl) { try { - const res = await fetch(`${COMMUNITY_BASE}/t/${topicId}.json`); + const res = await fetchWithRetry(`${COMMUNITY_BASE}/t/${topicId}.json`); if (!res.ok) { console.warn(` Skipping ${topicUrl}: HTTP ${res.status}`); return null; } - const data = await res.json(); + let data; + try { + data = await res.json(); + } catch { + console.warn(` Skipping ${topicUrl}: malformed JSON in topic response`); + return null; + } + const firstPagePosts = data.post_stream?.posts ?? []; const allPostIds = data.post_stream?.stream ?? []; @@ -120,10 +133,22 @@ async function fetchTopicPosts(topicId, topicUrl) { for (let i = 0; i < remainingIds.length; i += 20) { const chunk = remainingIds.slice(i, i + 20); const params = chunk.map((id) => `post_ids[]=${id}`).join("&"); - const chunkRes = await fetch(`${COMMUNITY_BASE}/t/${topicId}/posts.json?${params}`); + const chunkRes = await fetchWithRetry( + `${COMMUNITY_BASE}/t/${topicId}/posts.json?${params}` + ); if (chunkRes.ok) { - const chunkData = await chunkRes.json(); + let chunkData; + try { + chunkData = await chunkRes.json(); + } catch { + console.warn(` Failed to parse chunk JSON (posts ${chunk[0]}…${chunk[chunk.length - 1]})`); + continue; + } allPosts = allPosts.concat(chunkData.post_stream?.posts ?? []); + } else { + console.warn( + ` Failed to fetch chunk (posts ${chunk[0]}…${chunk[chunk.length - 1]}): HTTP ${chunkRes.status}` + ); } } @@ -199,7 +224,7 @@ async function main() { const oldJson = readFileSync(filePath, "utf-8"); if (newJson !== oldJson) { - writeFileSync(filePath, newJson); + atomicWrite(filePath, newJson); updated++; console.log(`Updated: ${filePath}`); } @@ -208,7 +233,9 @@ async function main() { console.log(`Done. ${updated} file(s) updated out of ${levelFiles.length} levels.`); } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/refresh-leaderboard.mjs b/scripts/refresh-leaderboard.mjs index 99580eb4c..df3209311 100644 --- a/scripts/refresh-leaderboard.mjs +++ b/scripts/refresh-leaderboard.mjs @@ -19,9 +19,10 @@ * NOTE: community.offon.dev is the actual Discourse server URL used for API calls. */ -import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { readFileSync, existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { atomicWrite, fetchWithRetry } from "./discourse-utils.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, ".."); @@ -30,8 +31,7 @@ const COMMUNITY_BASE = "https://community.offon.dev"; const QUERY_ID = 5; // Maps adventure ID -> Discourse category ID and which difficulty levels are active. -// 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. +// Updated by the sync-adventure workflow. 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 = { // GENERATED:adventures @@ -65,10 +65,20 @@ function loadDotEnv() { } } -function resolveAvatarUrl(url) { +/** + * Resolves a Discourse avatar URL to an absolute HTTPS URL. + * Returns undefined for any URL that does not resolve to https://. + * Exported for unit-testing. + */ +export function resolveAvatarUrl(url) { if (!url) return undefined; - const absolute = url.startsWith("http") ? url : `${COMMUNITY_BASE}${url}`; - return absolute.replace("/user_avatar/community.open-ecosystem.com/", "/user_avatar/community.offon.dev/"); + if (!url.startsWith("https://") && !url.startsWith("/")) return undefined; + const absolute = url.startsWith("https://") ? url : `${COMMUNITY_BASE}${url}`; + const normalized = absolute.replace( + "/user_avatar/community.open-ecosystem.com/", + "/user_avatar/community.offon.dev/" + ); + return normalized.startsWith("https://") ? normalized : undefined; } async function fetchLeaderboard(adventureId, { categoryId, has_beginner, has_intermediate, has_expert, has_single }, apiKey, apiUsername) { @@ -81,7 +91,7 @@ async function fetchLeaderboard(adventureId, { categoryId, has_beginner, has_int "params[has_single]": String(has_single), }); - const res = await fetch(url, { + const res = await fetchWithRetry(url, { method: "POST", headers: { "Api-Key": apiKey, @@ -95,7 +105,12 @@ async function fetchLeaderboard(adventureId, { categoryId, has_beginner, has_int throw new Error(`HTTP ${res.status} for ${adventureId}`); } - const data = await res.json(); + let data; + try { + data = await res.json(); + } catch { + throw new Error(`Malformed JSON in leaderboard response for ${adventureId}`); + } if (!data.success) { throw new Error(`Query failed for ${adventureId}: ${JSON.stringify(data.errors)}`); @@ -150,7 +165,12 @@ async function main() { let changed = 0; let errors = 0; - for (const [adventureId, config] of Object.entries(ADVENTURE_CATEGORIES)) { + const entries = Object.entries(ADVENTURE_CATEGORIES); + for (let i = 0; i < entries.length; i++) { + const [adventureId, config] = entries[i]; + // 2 s delay between adventures to stay within Discourse's admin rate limit + if (i > 0) await new Promise((r) => setTimeout(r, 2000)); + const activeCount = [config.has_beginner, config.has_intermediate, config.has_expert, config.has_single].filter(Boolean).length; const outPath = resolve(ADVENTURES_DIR, adventureId, "leaderboard.json"); console.log(` Fetching leaderboard: ${adventureId} (category ${config.categoryId}, ${activeCount} active levels)`); @@ -164,7 +184,7 @@ async function main() { const existingRows = JSON.stringify(existingParsed.rows ?? []); if (existingRows !== JSON.stringify(rows)) { - writeFileSync(outPath, payload); + atomicWrite(outPath, payload); console.log(` Updated: ${rows.length} entries`); changed++; } else { @@ -180,7 +200,9 @@ async function main() { if (errors > 0) process.exit(1); } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/sync-adventure.mjs b/scripts/sync-adventure.mjs index b9e585366..b16c2d481 100644 --- a/scripts/sync-adventure.mjs +++ b/scripts/sync-adventure.mjs @@ -21,8 +21,13 @@ 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_ORDER } from "./lib/level-constants.mjs"; -import { parseDeadline } from "./lib/deadline.mjs"; +import { LEVEL_ORDER } from "../src/lib/level-constants.mjs"; +import { parseDeadline } from "../src/lib/deadline.mjs"; + +// This script writes its output back into adventure.yaml, so an unparseable +// timezone must leave the author's original text alone rather than replace it +// with the sentinel. The build still gates on it when it parses the file. +const PRESERVE_TZ = { onUnknownTimezone: "preserve" }; import { findMissingUpstreamLevels, selectActiveLevels, @@ -123,12 +128,12 @@ function buildLevel(raw, adventureTags, rewardsDeadline) { const cleaned = transformStrings(rest, stripCodeInLinks); return { ...cleaned, - ...(cleaned.deadline && { deadline: parseDeadline(cleaned.deadline) }), + ...(cleaned.deadline && { deadline: parseDeadline(cleaned.deadline, PRESERVE_TZ) }), topics: cleaned.topics || deriveTopics(adventureTags), verification: cleaned.verification || VERIFICATION_STUB, // Fall back to the adventure-level rewards deadline when the level has no deadline of its own, // so the compact RewardsCard on ChallengeDetail always has a deadline to display. - ...(rewardsDeadline && !cleaned.deadline && { deadline: parseDeadline(rewardsDeadline) }), + ...(rewardsDeadline && !cleaned.deadline && { deadline: parseDeadline(rewardsDeadline, PRESERVE_TZ) }), }; } @@ -314,7 +319,7 @@ async function main() { ...(indexData.rewards && { rewards: { ...indexData.rewards, - ...(indexData.rewards.deadline && { deadline: parseDeadline(indexData.rewards.deadline) }), + ...(indexData.rewards.deadline && { deadline: parseDeadline(indexData.rewards.deadline, PRESERVE_TZ) }), }, }), // Preserve contributor set by a reviewer; omit otherwise (PR checklist item) diff --git a/serve.json b/serve.json deleted file mode 100644 index 1e9c7bc39..000000000 --- a/serve.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "trailingSlash": true -} diff --git a/src/Layout.tsx b/src/Layout.tsx deleted file mode 100644 index a677732d0..000000000 --- a/src/Layout.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { Outlet, useLocation } from "react-router"; -import { useEffect, useRef, useState, type JSX } from "react"; -import { ThemeProvider, useTheme } from "@/hooks/useTheme"; -import { ConsentBanner } from "@/components/ConsentBanner"; -import { ConsentProvider, useConsent } from "@/hooks/useConsent"; -import { useClickTracking } from "@/hooks/useClickTracking"; - -// On route change, either scroll to the top of the page or, when the URL has -// a hash, scroll the matching element into view. React Router does not handle -// hash anchor scrolling on client-side navigation by default, so cross-route -// links like /about#board would otherwise leave the user at the wrong scroll -// position. -const ScrollToTop = (): null => { - const { pathname, hash } = useLocation(); - const prevPathname = useRef<string | null>(null); - - useEffect(() => { - const prev = prevPathname.current; - prevPathname.current = pathname; - - if (hash) { - const id = hash.slice(1); - // Defer past commit so the target element exists in the new route. - requestAnimationFrame(() => { - document.getElementById(id)?.scrollIntoView({ block: "start" }); - }); - return; - } - - // Suppress scroll reset when navigating between /challenges and - // /challenges/:tag (e.g. clicking a TagChip or direct-linking to a tag). - // Filter interactions (topic/difficulty toggles) never change the pathname, - // so they don't reach this guard at all. - if (prev !== null && prev.startsWith("/challenges") && pathname.startsWith("/challenges")) return; - - window.scrollTo(0, 0); - }, [pathname, hash]); - return null; -}; - -// Fires gtag page_view on every SPA route change, but only when consent is -// granted. Pushing page_view events to dataLayer while gtag.js is not loaded -// would queue them; the moment the user later clicks Accept, gtag.js drains -// the queue and retroactively sends pageviews for every route the visitor -// browsed while consent was undecided or denied. Gating prevents that. -const PageViewTracker = (): null => { - const { pathname } = useLocation(); - const { consent } = useConsent(); - useEffect(() => { - if (consent !== "granted") return; - if (typeof window.gtag !== "function") return; - window.gtag("event", "page_view", { - page_path: pathname, - page_location: window.location.href, - page_title: document.title, - }); - }, [pathname, consent]); - return null; -}; - -const ClickTracker = (): null => { - useClickTracking(); - return null; -}; - -// Moves focus to #main-content after each SPA route change so keyboard and -// AT users land in the main content area rather than on <body>. -// All page <main> elements already carry id="main-content" tabIndex={-1}. -// Skips the initial mount (same pattern as RouteAnnouncer) to avoid an -// intrusive focus jump on first page load. -const FocusReset = (): null => { - const { pathname } = useLocation(); - const hasMounted = useRef(false); - - useEffect(() => { - if (!hasMounted.current) { - hasMounted.current = true; - return; - } - const raf = requestAnimationFrame(() => { - (document.getElementById("main-content") as HTMLElement | null)?.focus({ preventScroll: true }); - }); - return () => cancelAnimationFrame(raf); - }, [pathname]); - return null; -}; - -// Announces page title to screen readers on SPA navigation. Skips the initial -// mount so users don't hear an announcement when they first load the page. -const RouteAnnouncer = (): JSX.Element => { - const { pathname } = useLocation(); - const [announcement, setAnnouncement] = useState(""); - const hasMounted = useRef(false); - - useEffect(() => { - if (!hasMounted.current) { - hasMounted.current = true; - return; - } - const raf = requestAnimationFrame(() => { - setAnnouncement(document.title || pathname); - }); - return () => cancelAnimationFrame(raf); - }, [pathname]); - - return ( - <span role="status" aria-live="polite" aria-atomic="true" className="sr-only"> - {announcement} - </span> - ); -}; - -// Announces theme changes to screen readers. Skips the initial mount to avoid -// announcing the default theme on page load. -const ThemeAnnouncer = (): JSX.Element => { - const { theme } = useTheme(); - const [announcement, setAnnouncement] = useState(""); - const hasMounted = useRef(false); - - useEffect(() => { - if (!hasMounted.current) { - hasMounted.current = true; - return; - } - setAnnouncement(theme === "dark" ? "Switched to dark mode" : "Switched to light mode"); - const t = setTimeout(() => setAnnouncement(""), 1000); - return () => clearTimeout(t); - }, [theme]); - - return ( - <span role="status" aria-live="polite" aria-atomic="true" className="sr-only"> - {announcement} - </span> - ); -}; - -export function Layout(): JSX.Element { - return ( - <ThemeProvider> - <ConsentProvider> - <a href="#main-content" className="skip-nav"> - Skip to main content - </a> - <ScrollToTop /> - <FocusReset /> - <RouteAnnouncer /> - <ThemeAnnouncer /> - {/* Shared description for every external link. Referenced via - aria-describedby="new-tab-hint" so the "opens in a new tab" hint is - an accessible description, not part of each link's accessible name. - hidden still resolves for aria-describedby. */} - <span id="new-tab-hint" hidden>opens in a new tab</span> - <PageViewTracker /> - <ClickTracker /> - <ConsentBanner /> - <Outlet /> - </ConsentProvider> - </ThemeProvider> - ); -} - -export default Layout; diff --git a/src/components/Abbr.tsx b/src/components/Abbr.tsx deleted file mode 100644 index 5612a744f..000000000 --- a/src/components/Abbr.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useId, useRef, type JSX, type ReactNode } from "react"; -import { useAbbrTooltips } from "@/hooks/useAbbrTooltips"; - -type AbbrProps = { - title: string; - children: ReactNode; -}; - -// Inline abbreviation with an accessible expansion. Renders the same markup the -// content generator emits for prose <abbr> (data-title + an sr-only span linked -// by aria-describedby) and shares one tooltip implementation via -// useAbbrTooltips, so JSX and pre-rendered prose behave identically. The visible -// token stays the accessible name; the expansion is a description. Styling and -// the no-JS hover tooltip come from the global `abbr[data-title]` CSS rules. -export const Abbr = ({ title, children }: AbbrProps): JSX.Element => { - const descId = useId(); - const ref = useRef<HTMLSpanElement>(null); - useAbbrTooltips(ref, [title]); - - return ( - <span ref={ref}> - {/* No title attribute: it would trigger the browser's native tooltip - alongside the custom one. tabIndex makes the tooltip reachable by - keyboard and touch; useAbbrTooltips wires hover/focus/click/Escape. */} - {/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- focusable so keyboard and touch users can reveal the expansion tooltip */} - <abbr data-title={title} aria-describedby={descId} tabIndex={0}> - {children} - </abbr> - <span id={descId} className="sr-only">{title}</span> - </span> - ); -}; diff --git a/src/components/AboutSection.tsx b/src/components/AboutSection.tsx deleted file mode 100644 index 5849822db..000000000 --- a/src/components/AboutSection.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type { JSX } from "react"; -import { BRAND_NAME } from "@/data/constants"; -import { BulletList } from "@/components/BulletList"; -import { SectionLabel } from "@/components/SectionLabel"; - -export const AboutSection = (): JSX.Element => { - return ( - <div id="approach" className="pb-16"> - <div> - <SectionLabel>our foundation</SectionLabel> - - <div className="mb-8"> - <h2 id="mission" className="text-2xl font-bold text-foreground">Our Mission</h2> - <div className="mt-4 flex flex-col gap-4 max-w-3xl"> - <p className="text-lg text-foreground leading-relaxed"> - {BRAND_NAME} exists to sustain the people behind open source and create the maintainers of tomorrow. - </p> - <p className="text-dim leading-relaxed"> - We are a vendor-neutral, community-driven space built around one goal: supporting open source contributors and maintainers at every stage of their journey, from the first pull request to long-term stewardship. - </p> - <p className="text-dim leading-relaxed"> - Most platforms, foundations, and content formats today are designed with enterprises, legal teams, or marketing goals in mind. Far fewer are built for the individuals who actually keep open source alive. - </p> - <p className="text-dim leading-relaxed"> - As the ecosystem navigates new realities around regulation, digital sovereignty, and AI, {BRAND_NAME} gives the community a place to learn, share, mentor, and grow, from curious users to confident contributors, and from contributors to sustainable maintainers. - </p> - </div> - </div> - - <div className="mb-8"> - <h2 id="vision" className="text-2xl font-bold text-foreground">Our Vision</h2> - <div className="mt-4 flex flex-col gap-4 max-w-3xl"> - <p className="text-dim leading-relaxed"> - A future where open source is not only widely used, but actively sustained by a new generation of contributors and maintainers who are trained, supported, and connected, regardless of which company, country, or project they come from. - </p> - <p className="text-dim leading-relaxed"> - We believe a real community has to be open. Open in its tooling, open in its governance, and open in how knowledge is preserved and passed on. {BRAND_NAME} is our attempt to put that belief into practice. - </p> - </div> - </div> - - <div className="mb-8"> - <h2 id="audience" className="text-2xl font-bold text-foreground">Who It's For</h2> - <div className="mt-4 max-w-3xl"> - <BulletList items={[ - "Curious learners and developers looking for a practical, guided path into open source", - "Engineers, Site Reliability Engineers (SREs), and practitioners who want to deepen their skills through real-world challenges", - "Contributors and maintainers who want to share knowledge and help grow the next generation", - "Open Source Program Office (OSPO) members, advocates, and community builders who want to amplify their work and strengthen the wider ecosystem", - "Non-code contributors (writers, designers, translators, organisers) whose work is essential but often invisible", - "Organisations and sponsors who want to support open source learning and connect with the community on the community's terms", - ]} /> - </div> - </div> - - <div> - <h2 id="values" className="text-2xl font-bold text-foreground">What We Stand For</h2> - <div className="mt-4 max-w-3xl"> - <BulletList items={[ - { - lead: "Open and vendor-agnostic by default.", - desc: "Every challenge, tool, and piece of content is built around open source. Sponsors fund the work; they do not shape the mission.", - }, - { - lead: "Reproducible and action-oriented.", - desc: "Real-world scenarios, hands-on challenges, and practical knowledge you can apply, fork, and build on, not marketing in disguise.", - }, - { - lead: "Built for people, not pipelines.", - desc: `${BRAND_NAME} is not a sales channel and not a source of revenue. It exists to develop the maintainers of tomorrow.`, - }, - { - lead: "Knowledge that lasts.", - desc: `Unlike chat tools and social platforms that lose context the moment a thread scrolls away, ${BRAND_NAME} is designed to preserve, structure, and pass on what the community learns.`, - }, - { - lead: "Respectful and inclusive.", - desc: "Constructive feedback, zero tolerance for harassment, and a space where every contribution, whether code, docs, design, or mentorship, is visible and valued.", - }, - ]} /> - </div> - </div> - </div> - </div> - ); -}; diff --git a/src/components/AdventureCard.astro b/src/components/AdventureCard.astro new file mode 100644 index 000000000..c61ef1ebd --- /dev/null +++ b/src/components/AdventureCard.astro @@ -0,0 +1,76 @@ +--- +import IconLayers from "~icons/lucide/layers"; +import DifficultyBadge from "@/components/DifficultyBadge.astro"; +import ContributorBadge from "@/components/ContributorBadge.astro"; +import LivePill from "@/components/LivePill.astro"; +import AdventureIcon from "@/components/AdventureIcon.astro"; +import { stripHtml } from "@/lib/markdown"; +import type { Difficulty } from "@/lib/difficulty"; + +interface Props { + adventure: { + slug: string; + title: string; + story: string; + tags: string[]; + icon?: string; + isLive?: boolean; + levels: { id: string; difficulty: Difficulty }[]; + contributor?: { name: string; url?: string }; + }; +} +const { adventure } = Astro.props; +const base = import.meta.env.BASE_URL; +const difficulties = adventure.levels.map((l) => l.difficulty).join(", "); +const tagList = adventure.tags.slice(0, 4).join(", "); +const live = adventure.isLive ? ", live" : ""; +const label = tagList + ? `${adventure.title}: ${difficulties}${live}, ${tagList}` + : `${adventure.title}: ${difficulties}${live}`; +--- + +<a + href={`${base}adventures/${adventure.slug}/`} + aria-label={label} + class:list={[ + "group card-glow relative flex flex-col rounded-xl border-2 bg-[hsl(var(--surface))] p-6 focus-ring", + adventure.isLive ? "border-primary/50" : "border-border", + ]} +> + <div class="mb-3 flex items-center justify-between"> + <span class="font-mono text-xs text-muted-foreground">Adventure</span> + <div class="flex items-center gap-2"> + {adventure.isLive && <LivePill />} + <span + class="badge-levels inline-flex items-center gap-1.5 rounded-sm border border-primary/30 bg-primary/10 px-2.5 py-1 font-mono text-xs uppercase tracking-wider text-primary" + > + <IconLayers width={12} height={12} aria-hidden="true" /> + {adventure.levels.length} Level{adventure.levels.length !== 1 ? "s" : ""} + </span> + </div> + </div> + + <div class="flex items-center gap-1"> + <h3 class="text-lg font-semibold text-foreground transition-colors group-hover:text-primary"> + {adventure.title} + </h3> + <AdventureIcon icon={adventure.icon} size={16} class="shrink-0 text-muted-foreground" /> + </div> + <p class="mt-2 text-sm text-muted-foreground line-clamp-2">{stripHtml(adventure.story)}</p> + + <div class="mt-4 flex flex-wrap items-center gap-2"> + {adventure.levels.map((level) => <DifficultyBadge difficulty={level.difficulty} />)} + </div> + + <div class="mt-4 flex flex-wrap gap-1.5"> + {adventure.tags.slice(0, 4).map((tag) => ( + <span class="rounded-sm border border-border px-2 py-0.5 text-xs text-faint">{tag}</span> + ))} + </div> + + {adventure.contributor && ( + <div class="mt-auto pt-4"> + <ContributorBadge name={adventure.contributor.name} /> + </div> + )} +</a> diff --git a/src/components/AdventureCard.tsx b/src/components/AdventureCard.tsx deleted file mode 100644 index c4e785c41..000000000 --- a/src/components/AdventureCard.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import type { JSX } from "react"; -import { Link } from "react-router"; -import { Layers } from "lucide-react"; -import type { AdventureCardSummary } from "@/data/adventures"; -import { cn } from "@/lib/utils"; -import { DifficultyBadge } from "@/components/DifficultyBadge"; -import { ContributorBadge } from "@/components/ContributorBadge"; -import { LivePill } from "@/components/LivePill"; -import { AdventureIcon } from "@/components/AdventureIcon"; - - -type AdventureCardProps = { adventure: AdventureCardSummary }; - -export const AdventureCard = ({ adventure }: AdventureCardProps): JSX.Element => { - const difficulties = adventure.levels.map((l) => l.difficulty).join(", "); - const tags = adventure.tags.slice(0, 4).join(", "); - const label = tags - ? `${adventure.title}: ${difficulties}, ${tags}` - : `${adventure.title}: ${difficulties}`; - - return ( - <Link - to={`/adventures/${adventure.id}/`} - aria-label={label} - className={cn( - "group card-glow relative rounded-xl border-2 bg-[hsl(var(--surface))] p-6 flex flex-col focus-ring", - adventure.isLive - ? "border-primary/50" - : "border-border" - )} - > - <div className="flex items-center justify-between mb-3"> - <span className="font-mono text-xs text-muted-foreground">Adventure</span> - <div className="flex items-center gap-2"> - {adventure.isLive && <LivePill />} - <span className="badge-levels inline-flex items-center gap-1.5 rounded-sm border border-primary/30 bg-primary/10 px-2.5 py-1 font-mono text-xs uppercase tracking-wider text-primary"> - <Layers className="h-3 w-3" aria-hidden="true" /> - {adventure.levels.length} Level{adventure.levels.length !== 1 ? "s" : ""} - </span> - </div> - </div> - - <div className="flex items-center gap-1"> - <h3 className="text-lg font-semibold text-foreground group-hover:text-primary transition-colors"> - {adventure.title} - </h3> - <AdventureIcon icon={adventure.icon} size={16} className="shrink-0 text-muted-foreground" /> - </div> - <p className="mt-2 text-sm text-muted-foreground line-clamp-2">{adventure.story}</p> - - <div className="mt-4 flex flex-wrap items-center gap-2"> - {adventure.levels.map((level) => ( - <DifficultyBadge key={level.id} difficulty={level.difficulty} /> - ))} - </div> - - <div className="mt-4 flex flex-wrap gap-1.5"> - {adventure.tags.slice(0, 4).map((tag) => ( - <span key={tag} className="rounded-sm border border-border px-2 py-0.5 text-xs text-faint"> - {tag} - </span> - ))} - </div> - - {adventure.contributor && ( - <div className="mt-auto pt-4"> - <ContributorBadge name={adventure.contributor.name} /> - </div> - )} - </Link> - ); -}; diff --git a/src/components/AdventureIcon.astro b/src/components/AdventureIcon.astro new file mode 100644 index 000000000..76576ecb8 --- /dev/null +++ b/src/components/AdventureIcon.astro @@ -0,0 +1,15 @@ +--- +import { LUCIDE_ICONS } from "@/lib/lucide-icons"; +import { ICON_TO_KEBAB } from "@/lib/adventure-icons"; + +interface Props { + icon?: string; + size?: number; + class?: string; +} +const { icon, size = 16, class: className } = Astro.props; +const kebab = icon ? ICON_TO_KEBAB[icon as keyof typeof ICON_TO_KEBAB] : undefined; +const IconComponent = kebab ? LUCIDE_ICONS[kebab] : undefined; +--- + +{IconComponent && <IconComponent width={size} height={size} aria-hidden="true" class={className} />} diff --git a/src/components/AdventureIcon.tsx b/src/components/AdventureIcon.tsx deleted file mode 100644 index c6cf607de..000000000 --- a/src/components/AdventureIcon.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { JSX } from "react"; -import { Building2, Compass, Cloud, FlaskConical, Satellite, Scale, Telescope, type LucideIcon } from "lucide-react"; - -const ICONS: Record<string, LucideIcon> = { - Building2, - Compass, - Cloud, - FlaskConical, - Satellite, - Scale, - Telescope, -}; - -type AdventureIconProps = { - icon?: string; - size?: number; - className?: string; -}; - -export const AdventureIcon = ({ icon, size = 16, className }: AdventureIconProps): JSX.Element | null => { - if (!icon) return null; - const Icon = ICONS[icon]; - if (!Icon) return null; - return <Icon size={size} aria-hidden="true" className={className} />; -}; diff --git a/src/components/AdventureLeaderboard.tsx b/src/components/AdventureLeaderboard.tsx deleted file mode 100644 index dba26a869..000000000 --- a/src/components/AdventureLeaderboard.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { JSX } from "react"; -import { useAdventureLeaderboard } from "@/hooks/useAdventureLeaderboard"; -import { LeaderboardList } from "@/components/LeaderboardList"; - -type AdventureLeaderboardProps = { - adventureId: string; -}; - -/** - * Sidebar card showing the ranked leaderboard for an adventure. - * Data is fetched from the per-adventure leaderboard.json by useAdventureLeaderboard - * and refreshed daily by the GitHub Actions workflow. Returns null when no data exists. - */ -export const AdventureLeaderboard = ({ adventureId }: AdventureLeaderboardProps): JSX.Element | null => { - const { rows } = useAdventureLeaderboard(adventureId); - - if (rows.length === 0) return null; - - return ( - <div className="rounded-xl border border-border bg-[hsl(var(--surface))] p-5"> - <h2 className="font-sans text-base font-semibold text-foreground mb-4">Leaderboard</h2> - <LeaderboardList rows={rows} label="Adventure leaderboard" /> - </div> - ); -}; diff --git a/src/components/ArchitectureSection.tsx b/src/components/ArchitectureSection.tsx deleted file mode 100644 index 4b2481b92..000000000 --- a/src/components/ArchitectureSection.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { JSX } from "react"; -import { CollapsibleSection } from "@/components/CollapsibleSection"; -import { MarkdownContent } from "@/components/MarkdownContent"; - -type ArchitectureSectionProps = { - architecture?: string; - diagram?: string; - diagramAlt?: string; - ascii?: string; -}; - -export const ArchitectureSection = ({ architecture, diagram, diagramAlt, ascii }: ArchitectureSectionProps): JSX.Element => ( - <CollapsibleSection id="architecture" title="Architecture" headingLevel={2}> - <div className="space-y-4"> - {diagram ? ( - <img - src={diagram} - alt={diagramAlt ?? "Architecture diagram"} - loading="lazy" - decoding="async" - width={1200} - height={560} - className="w-full h-auto max-h-[560px] object-contain block rounded-lg" - /> - ) : ascii ? ( - // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- tabIndex={0} makes this scrollable block keyboard-reachable per WCAG 2.1 SC 2.1.1 - <pre tabIndex={0} aria-label="Architecture diagram" className="overflow-x-auto rounded-lg border border-border bg-background/60 px-4 py-3 font-mono text-xs text-foreground leading-relaxed whitespace-pre"> - {ascii} - </pre> - ) : null} - {architecture && <MarkdownContent source={architecture} />} - </div> - </CollapsibleSection> -); diff --git a/src/components/AvatarLink.astro b/src/components/AvatarLink.astro new file mode 100644 index 000000000..682e7e4e1 --- /dev/null +++ b/src/components/AvatarLink.astro @@ -0,0 +1,36 @@ +--- +// Avatar + username row used in the community leaderboards. Despite the name it +// is not an anchor. The avatar is external (Discourse); when a URL is present we +// render the image, otherwise an initials chip. An inline onerror swaps a failed +// image for the initials chip (CSP allows 'unsafe-inline'). +interface Props { + username: string; + avatarUrl?: string; + size?: 24 | 28; + class?: string; +} +const { username, avatarUrl, size = 24, class: className = "" } = Astro.props; +const sizeClass = size === 28 ? "h-7 w-7" : "h-6 w-6"; +const initials = username.slice(0, 2).toUpperCase(); +const chipClass = `flex ${sizeClass} shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-foreground`; +--- + +{avatarUrl ? ( + <img + src={avatarUrl} + alt="" + aria-hidden="true" + width={size} + height={size} + loading="lazy" + decoding="async" + class={`${sizeClass} rounded-full shrink-0 object-cover`} + onerror={`this.style.display='none';this.nextElementSibling.style.display='flex';`} + /> + <span class={chipClass} style="display:none" aria-hidden="true">{initials}</span> +) : ( + <span class={chipClass} aria-hidden="true">{initials}</span> +)} +<span class={className}> + <span class="truncate">{username}</span> +</span> diff --git a/src/components/AvatarLink.tsx b/src/components/AvatarLink.tsx deleted file mode 100644 index 6ad2049f2..000000000 --- a/src/components/AvatarLink.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useState, type CSSProperties, type JSX } from "react"; - -type AvatarLinkProps = { - username: string; - avatarUrl?: string; - /** Avatar diameter in pixels. Defaults to 24. */ - size?: 24 | 28; - /** Inline style for the initials fallback (background + text color). */ - avatarFallbackStyle?: CSSProperties; - /** Class applied to the username span element. */ - className: string; -}; - -const SIZE_CLASSES: Record<24 | 28, string> = { - 24: "h-6 w-6", - 28: "h-7 w-7", -}; - -export const AvatarLink = ({ - username, - avatarUrl, - size = 24, - avatarFallbackStyle, - className, -}: AvatarLinkProps): JSX.Element => { - const sizeClass = SIZE_CLASSES[size]; - // Avatars are external (Discourse). If one fails to load, fall back to the - // initials chip instead of leaving a broken image. - const [imgFailed, setImgFailed] = useState(false); - - return ( - <> - {avatarUrl && !imgFailed ? ( - <img - src={avatarUrl} - alt="" - aria-hidden="true" - width={size} - height={size} - loading="lazy" - decoding="async" - onError={() => setImgFailed(true)} - className={`${sizeClass} rounded-full shrink-0 object-cover`} - /> - ) : ( - <span - className={`flex ${sizeClass} shrink-0 items-center justify-center rounded-full bg-muted text-[0.6rem] font-semibold text-foreground`} - style={avatarFallbackStyle} - aria-hidden="true" - > - {username.slice(0, 2).toUpperCase()} - </span> - )} - <span className={className}> - <span className="truncate">{username}</span> - </span> - </> - ); -}; diff --git a/src/components/BoardSection.tsx b/src/components/BoardSection.tsx deleted file mode 100644 index 49b19cabe..000000000 --- a/src/components/BoardSection.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { JSX } from "react"; -import { User } from "lucide-react"; -import { BRAND_NAME } from "@/data/constants"; -import { BOARD_MEMBERS } from "@/data/team"; -import { PersonNameLink } from "@/components/PersonNameLink"; -import { SectionLabel } from "@/components/SectionLabel"; - -const AVATAR_SIZE = 80; - -export const BoardSection = (): JSX.Element => { - return ( - <section id="board" aria-labelledby="board-heading" className="pb-16"> - <div> - <SectionLabel>the people</SectionLabel> - <h2 id="board-heading" className="text-2xl font-bold text-foreground">Board</h2> - <p className="mt-4 max-w-3xl text-muted-foreground leading-relaxed"> - The board guides the long-term direction of {BRAND_NAME}, stewarding its values, governance, and community priorities. - </p> - <div className="mt-8 grid gap-4 sm:grid-cols-2"> - {BOARD_MEMBERS.map((member, index) => ( - <div - key={`${member.name}-${index}`} - className="card-glow rounded-xl border border-border bg-[hsl(var(--surface))] p-6" - > - {member.image ? ( - <img - src={`${import.meta.env.BASE_URL}${member.image}`} - alt={member.name} - width={AVATAR_SIZE} - height={AVATAR_SIZE} - loading="lazy" - decoding="async" - className="h-20 w-20 rounded-full object-cover" - /> - ) : ( - <div - aria-hidden="true" - className="flex h-20 w-20 items-center justify-center rounded-full border border-border bg-muted" - > - <User size={28} className="text-muted-foreground" /> - </div> - )} - <div className="mt-4"> - <PersonNameLink name={member.name} url={member.url} /> - <p className="mt-1.5 text-sm text-muted-foreground leading-relaxed">{member.about}</p> - </div> - </div> - ))} - </div> - </div> - </section> - ); -}; diff --git a/src/components/BottomCTA.astro b/src/components/BottomCTA.astro new file mode 100644 index 000000000..8588d5a93 --- /dev/null +++ b/src/components/BottomCTA.astro @@ -0,0 +1,55 @@ +--- +import IconExternalLink from "~icons/lucide/external-link"; +import { BRAND_NAME, BRAND_SECONDARY_LINE_PARTS, CHALLENGES_REPO_URL, COMMUNITY_URL } from "@/lib/site"; + +const base = import.meta.env.BASE_URL; +const secondaryLine = BRAND_SECONDARY_LINE_PARTS.join(" "); +--- + +<section aria-labelledby="bottom-cta-heading" class="bg-primary py-16 px-6 md:px-16 relative overflow-hidden"> + <div class="mx-auto max-w-6xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-[1fr_1fr_auto] gap-12 lg:gap-16 items-center"> + {/* Left - headline */} + <div> + <h2 id="bottom-cta-heading" class="text-4xl md:text-5xl font-bold leading-tight tracking-tight text-primary-foreground"> + <span class="block">Start Curious.</span> + <span class="block">Break Things.</span> + <span class="block">Learn Together.</span> + <span class="block">Glow Brighter.</span> + </h2> + </div> + + {/* Right - copy + buttons */} + <div class="flex flex-col gap-4"> + <p class="font-sans text-base leading-relaxed text-background/90"> + <strong class="font-medium text-primary-foreground">You bring your questions, your fixes, your ideas.</strong>{" "} + We bring the challenges, the tooling, and the practitioners who care about the same problems you do. + </p> + <p class="font-sans text-base leading-relaxed text-background/90"> + Every spark starts with one person. Like Nyx, our firefly, together we brighten the whole open source ecosystem. + </p> + <p class="font-sans text-base font-medium text-background/90"> + That's {BRAND_NAME}. {secondaryLine} + </p> + <div class="flex gap-3 flex-wrap mt-2"> + <a href={COMMUNITY_URL} target="_blank" rel="noopener noreferrer" aria-describedby="new-tab-hint" class="btn-inverse"> + Join the Community <IconExternalLink width={14} height={14} aria-hidden="true" /> + </a> + <a href={CHALLENGES_REPO_URL} target="_blank" rel="noopener noreferrer" aria-describedby="new-tab-hint" class="btn-ghost-inverse"> + View Challenges on GitHub <IconExternalLink width={14} height={14} aria-hidden="true" /> + </a> + </div> + </div> + + {/* Nyx mascot - third column, visible on lg+ only */} + <img + src={`${base}nyx.webp`} + alt="" + aria-hidden="true" + width={240} + height={240} + loading="lazy" + decoding="async" + class="hidden lg:block w-[240px] h-[240px] self-start" + /> + </div> +</section> diff --git a/src/components/BottomCTA.tsx b/src/components/BottomCTA.tsx deleted file mode 100644 index 2ac967421..000000000 --- a/src/components/BottomCTA.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import type { JSX } from "react"; -import { ExternalLink } from "lucide-react"; -import { BRAND_NAME, BRAND_SECONDARY_LINE, COMMUNITY_URL } from "@/data/constants"; - -export const BottomCTA = (): JSX.Element => { - return ( - <section aria-labelledby="bottom-cta-heading" className="bg-primary py-16 px-6 md:px-16 relative overflow-hidden"> - <div className="mx-auto max-w-6xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-[1fr_1fr_auto] gap-12 lg:gap-16 items-center"> - {/* Left - headline */} - <div> - <h2 id="bottom-cta-heading" className="text-4xl md:text-5xl font-bold leading-tight tracking-tight text-primary-foreground"> - <span className="block">Start Curious.</span> - <span className="block">Break Things.</span> - <span className="block">Learn Together.</span> - <span className="block">Glow Brighter.</span> - </h2> - </div> - - {/* Right - copy + buttons */} - <div className="flex flex-col gap-4"> - <p className="font-sans text-base leading-relaxed text-background/90"> - <strong className="font-medium text-primary-foreground"> - You bring your questions, your fixes, your ideas. - </strong>{" "} - We bring the challenges, the tooling, and the practitioners who care about the same problems you do. - </p> - <p className="font-sans text-base leading-relaxed text-background/90"> - Every spark starts with one person. Like Nyx, our firefly, together we brighten the whole open source ecosystem. - </p> - <p className="font-sans text-base font-medium text-background/90"> - That's {BRAND_NAME}. {BRAND_SECONDARY_LINE} - </p> - <div className="flex gap-3 flex-wrap mt-2"> - <a - href={COMMUNITY_URL} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - className="btn-inverse" - > - Join the Community <ExternalLink size={14} aria-hidden="true" /> - </a> - <a - href="https://github.com/off-on-dev/open-source-challenges" - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - className="btn-ghost-inverse" - > - View Challenges on GitHub <ExternalLink size={14} aria-hidden="true" /> - </a> - </div> - </div> - - {/* Nyx mascot - third column, visible on lg+ only */} - <img - src={`${import.meta.env.BASE_URL}nyx.webp`} - alt="" - aria-hidden="true" - width={240} - height={240} - loading="lazy" - decoding="async" - className="hidden lg:block w-[240px] h-[240px] self-start" - /> - </div> - </section> - ); -}; diff --git a/src/components/BrandStory.tsx b/src/components/BrandStory.tsx deleted file mode 100644 index a7d4fd547..000000000 --- a/src/components/BrandStory.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { JSX } from "react"; -import { SectionLabel } from "@/components/SectionLabel"; -import { BRAND_NAME } from "@/data/constants"; - -export const BrandStory = (): JSX.Element => { - return ( - <section id="story" aria-labelledby="story-heading" className="pb-16"> - <div> - <SectionLabel>our story</SectionLabel> - <h2 id="story-heading" className="text-2xl font-bold text-foreground">The Story Behind the Firefly</h2> - <div className="mt-4 flex flex-col gap-4 max-w-3xl"> - <p className="text-dim leading-relaxed"> - When we rebranded, we wanted the name and the mascot to feel true to this community. We talked through a lot of ideas and kept coming back to the same question: what mascot could reflect a space built on curiosity, contribution, and learning by doing? - </p> - <p className="text-dim leading-relaxed"> - At one point, someone said firefly, and it clicked. That is how Nyx came to life. - </p> - <p className="text-dim leading-relaxed"> - Nyx is more than a mascot. The firefly reflects what {BRAND_NAME} is about: people showing up for each other, sharing what they know, and building something brighter together. Each person brings their own spark, but together, we glow brighter. - </p> - <p className="text-dim leading-relaxed"> - That spark matters more than ever. AI can write code, but when it takes over all the work, how do people discover their passion or the creativity needed to design the best solutions? When curiosity fades, growth stops. - </p> - <p className="text-dim leading-relaxed"> - Understanding how things work still matters. When we combine our thinking, that is where the real magic happens. The power of community is not just about being part of something. It is an engine that fuels human connection, curiosity, and genuine love for a topic. AI cannot replicate that experience. - </p> - </div> - </div> - </section> - ); -}; diff --git a/src/components/Breadcrumb.astro b/src/components/Breadcrumb.astro new file mode 100644 index 000000000..ed5f450ad --- /dev/null +++ b/src/components/Breadcrumb.astro @@ -0,0 +1,33 @@ +--- +import IconChevronRight from "~icons/lucide/chevron-right"; + +interface Props { + items: { label: string; href?: string }[]; + class?: string; +} +const { items, class: className = "mb-5" } = Astro.props; +const base = import.meta.env.BASE_URL; +const resolve = (href: string): string => (href.startsWith("http") ? href : base + href.replace(/^\//, "")); +--- + +<nav aria-label="Breadcrumb" class={className}> + <ol class="flex flex-wrap items-center gap-1 text-xs text-faint"> + { + items.map((item, i) => ( + <li class="flex items-center gap-1"> + {i > 0 && <IconChevronRight width={12} height={12} aria-hidden="true" class="shrink-0" />} + {item.href ? ( + <a + href={resolve(item.href)} + class="focus-ring-subtle inline-flex min-h-6 items-center rounded-sm transition-colors hover:text-foreground" + > + {item.label} + </a> + ) : ( + <span aria-current="page">{item.label}</span> + )} + </li> + )) + } + </ol> +</nav> diff --git a/src/components/Breadcrumb.tsx b/src/components/Breadcrumb.tsx deleted file mode 100644 index 98246a814..000000000 --- a/src/components/Breadcrumb.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { type JSX } from "react"; -import { Link } from "react-router"; -import { ChevronRight } from "lucide-react"; - -type BreadcrumbItem = { - label: string; - href?: string; -}; - -type BreadcrumbProps = { - items: BreadcrumbItem[]; - className?: string; -}; - -export const Breadcrumb = ({ items, className = "mb-5" }: BreadcrumbProps): JSX.Element => ( - <nav aria-label="Breadcrumb" className={className}> - <ol className="flex flex-wrap items-center gap-1 text-xs text-faint"> - {items.map((item, index) => ( - <li key={item.label} className="flex items-center gap-1"> - {index > 0 && ( - <ChevronRight size={12} aria-hidden="true" className="shrink-0" /> - )} - {item.href ? ( - <Link - to={item.href} - className="inline-flex min-h-6 items-center hover:text-foreground transition-colors focus-ring-subtle rounded-sm" - > - {item.label} - </Link> - ) : ( - <span aria-current="page">{item.label}</span> - )} - </li> - ))} - </ol> - </nav> -); diff --git a/src/components/BulletList.tsx b/src/components/BulletList.tsx deleted file mode 100644 index 7daa12b2c..000000000 --- a/src/components/BulletList.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import type { JSX } from "react"; -import { Check, X } from "lucide-react"; - -type BulletItem = string | { lead: string; desc: string }; - -type Marker = "dot" | "check" | "x"; - -type Spacing = "tight" | "loose"; - -type BulletListProps = { - items: BulletItem[]; - marker?: Marker; - spacing?: Spacing; -}; - -const itemKey = (item: BulletItem): string => - typeof item === "string" ? item : item.lead; - -const ulClass = (spacing: Spacing): string => - spacing === "loose" ? "space-y-3" : "flex flex-col gap-2"; - -const liClass = (spacing: Spacing): string => - spacing === "loose" - ? "flex items-start gap-3 text-sm text-muted-foreground leading-relaxed" - : "flex items-start gap-2.5 text-sm text-dim"; - -function MarkerIcon({ marker }: { marker: Marker }): JSX.Element { - if (marker === "check") { - return <Check size={14} aria-hidden="true" className="mt-0.5 shrink-0 text-foreground" />; - } - if (marker === "x") { - return <X size={14} aria-hidden="true" className="mt-0.5 shrink-0 text-foreground" />; - } - return <span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" aria-hidden="true" />; -} - -export const BulletList = ({ - items, - marker = "dot", - spacing = "tight", -}: BulletListProps): JSX.Element => ( - <ul role="list" className={ulClass(spacing)}> - {items.map((item) => ( - <li key={itemKey(item)} className={liClass(spacing)}> - <MarkerIcon marker={marker} /> - {typeof item === "string" ? ( - item - ) : ( - <span> - <strong className="font-semibold text-foreground">{item.lead}</strong> {item.desc} - </span> - )} - </li> - ))} - </ul> -); diff --git a/src/components/ChallengeBuildersSection.astro b/src/components/ChallengeBuildersSection.astro new file mode 100644 index 000000000..66a318e56 --- /dev/null +++ b/src/components/ChallengeBuildersSection.astro @@ -0,0 +1,50 @@ +--- +import { ADVENTURE_CONTRIBUTORS } from "@/data/team"; +import PersonNameLink from "@/components/PersonNameLink.astro"; +import InlineProse from "@/components/InlineProse.astro"; + +const base = import.meta.env.BASE_URL; +const hasAside = Astro.slots.has("aside"); +const contributors = ADVENTURE_CONTRIBUTORS; +--- + +{contributors.length > 0 && ( + <section id="challenge-builders" aria-labelledby="challenge-builders-heading" class="px-6 md:px-16 pb-16"> + <div class="mx-auto max-w-6xl"> + <div class={hasAside ? "grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-12" : ""}> + <div> + <h2 id="challenge-builders-heading" class="text-2xl font-bold text-foreground">Challenge Builders</h2> + <p class="mt-4 max-w-3xl text-muted-foreground leading-relaxed"> + Adventures don't build themselves. A heartfelt thank you to everyone who has put in the time and care to create them. + </p> + <div class="mt-8 grid gap-4 sm:grid-cols-2"> + {contributors.map((contributor) => ( + <div class="card-glow rounded-xl border border-border bg-[hsl(var(--surface))] p-6"> + <PersonNameLink name={contributor.name} url={contributor.url} /> + {contributor.aboutHtml && ( + <InlineProse html={contributor.aboutHtml} class="mt-1.5 text-sm text-muted-foreground leading-relaxed" /> + )} + <p class="mt-6 mb-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">adventures created</p> + <ul role="list" class="space-y-3"> + {contributor.adventures.map(({ id, title }) => ( + <li class="flex items-center gap-2 text-xs"> + <span class="h-1 w-1 shrink-0 rounded-full bg-primary" aria-hidden="true" /> + <a href={`${base}adventures/${id}/`} class="docs-ext-link">{title}</a> + </li> + ))} + </ul> + </div> + ))} + </div> + </div> + {hasAside && ( + <div class="hidden lg:block"> + <div class="sticky top-24"> + <slot name="aside" /> + </div> + </div> + )} + </div> + </div> + </section> +)} diff --git a/src/components/ChallengeBuildersSection.tsx b/src/components/ChallengeBuildersSection.tsx deleted file mode 100644 index 85cd86760..000000000 --- a/src/components/ChallengeBuildersSection.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import type { JSX, ReactNode } from "react"; -import { Link } from "react-router"; -import { ADVENTURE_CONTRIBUTORS } from "@/data/adventures/summaries"; -import { PersonNameLink } from "@/components/PersonNameLink"; -import { SidebarLayout } from "@/components/SidebarLayout"; -import { InlineProse } from "@/components/InlineProse"; - -export const ChallengeBuildersSection = ({ aside }: { aside?: ReactNode }): JSX.Element | null => { - if (ADVENTURE_CONTRIBUTORS.length === 0) { - return null; - } - - const content = ( - <div> - <h2 id="challenge-builders-heading" className="text-2xl font-bold text-foreground">Challenge Builders</h2> - <p className="mt-4 max-w-3xl text-muted-foreground leading-relaxed"> - Adventures don't build themselves. A heartfelt thank you to everyone who has put in the time and care to create them. - </p> - <div className="mt-8 grid gap-4 sm:grid-cols-2"> - {ADVENTURE_CONTRIBUTORS.map((contributor) => ( - <div - key={contributor.name} - className="card-glow rounded-xl border border-border bg-[hsl(var(--surface))] p-6" - > - <PersonNameLink name={contributor.name} url={contributor.url} /> - {contributor.aboutHtml && ( - <InlineProse html={contributor.aboutHtml} className="mt-1.5 text-sm text-muted-foreground leading-relaxed" /> - )} - <p className="mt-6 mb-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">adventures created</p> - <ul role="list" className="space-y-3"> - {contributor.adventures.map(({ id, title }) => ( - <li key={id} className="flex items-center gap-2 text-xs"> - <span className="h-1 w-1 shrink-0 rounded-full bg-primary" aria-hidden="true" /> - <Link - to={`/adventures/${id}/`} - className="docs-ext-link" - > - {title} - </Link> - </li> - ))} - </ul> - </div> - ))} - </div> - </div> - ); - - return ( - <section id="challenge-builders" aria-labelledby="challenge-builders-heading" className="px-6 md:px-16 pb-16"> - <div className="mx-auto max-w-6xl"> - <SidebarLayout aside={aside}>{content}</SidebarLayout> - </div> - </section> - ); -}; diff --git a/src/components/ChallengeFilters.tsx b/src/components/ChallengeFilters.tsx deleted file mode 100644 index c5299f77e..000000000 --- a/src/components/ChallengeFilters.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import { useState, useEffect, useRef, useId, type JSX, type CSSProperties, type FocusEvent } from "react"; -import { useEscapeKey } from "@/hooks/useEscapeKey"; -import type { KeyboardEvent as ReactKeyboardEvent } from "react"; -import { ChevronDown, Check, X } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { DIFFICULTIES, type Difficulty } from "@/data/adventures/filter-utils"; -import { DIFFICULTY_VAR, difficultyStyle } from "@/lib/difficulty"; - -const difficultyPillStyle = (diff: Difficulty, isActive: boolean): CSSProperties => { - const v = DIFFICULTY_VAR[diff]; - return { - ...difficultyStyle(diff), - // borderStyle must be set inline because DIFF_PILL_BASE's `border` class is the - // only source of border-style:solid; inline borderWidth/borderColor alone don't render borders. - borderStyle: "solid", - borderWidth: "2px", - borderColor: isActive ? `hsl(var(--difficulty-${v}))` : `hsl(var(--difficulty-${v}-border))`, - }; -}; - -// border-style in the `border` class of DIFF_PILL_BASE is what enables inline borderWidth/borderColor. -const DIFF_PILL_BASE = "filter-pill inline-flex items-center gap-1.5 rounded-full border px-4 py-1.5 min-h-[44px] text-sm font-medium leading-none transition-all duration-200 focus-ring cursor-pointer"; - -const allLevelsPillStyle = (isActive: boolean): CSSProperties => ({ - borderStyle: "solid", - backgroundColor: isActive ? "hsl(var(--foreground))" : "transparent", - borderColor: isActive ? "hsl(var(--foreground))" : "hsl(var(--foreground) / 0.6)", - borderWidth: "2px", - color: isActive ? "hsl(var(--background))" : "hsl(var(--foreground))", -}); - -// Visual inverse of allLevelsPillStyle: outlined (transparent bg) instead of filled. -// Inactive state mirrors pill-inactive: border-border + text-secondary (text-dim). -const allToolsPillStyle = (isActive: boolean): CSSProperties => ({ - borderStyle: "solid", - backgroundColor: "transparent", - borderColor: isActive ? "hsl(var(--foreground))" : "hsl(var(--border))", - borderWidth: "2px", - color: isActive ? "hsl(var(--foreground))" : "hsl(var(--text-secondary))", -}); - -export type { Difficulty }; - -type ChallengeFiltersProps = { - activeTopics: string[]; - activeDifficulty: string | null; - tags: string[]; - onDifficultyChange: (diff: Difficulty | null) => void; - onTopicsChange: (topics: string[]) => void; -}; - -export const ChallengeFilters = ({ - activeTopics, - activeDifficulty, - tags, - onDifficultyChange, - onTopicsChange, -}: ChallengeFiltersProps): JSX.Element => { - const [difficultyOpen, setDifficultyOpen] = useState(false); - const [tagsOpen, setTagsOpen] = useState(false); - const difficultyRef = useRef<HTMLDivElement>(null); - const tagsRef = useRef<HTMLDivElement>(null); - const difficultyTriggerRef = useRef<HTMLButtonElement>(null); - const tagsTriggerRef = useRef<HTMLButtonElement>(null); - const difficultyGroupId = useId(); - const tagsGroupId = useId(); - - useEffect(() => { - const handleClickOutside = (e: MouseEvent): void => { - const target = e.target as Node; - if (difficultyRef.current && !difficultyRef.current.contains(target)) { - setDifficultyOpen(false); - } - if (tagsRef.current && !tagsRef.current.contains(target)) { - setTagsOpen(false); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - - useEscapeKey(() => { - if (difficultyOpen) { - setDifficultyOpen(false); - difficultyTriggerRef.current?.focus(); - } else if (tagsOpen) { - setTagsOpen(false); - tagsTriggerRef.current?.focus(); - } - }, difficultyOpen || tagsOpen); - - const handleDifficultyClick = (diff: Difficulty): void => { - onDifficultyChange(activeDifficulty === diff ? null : diff); - }; - - const handleTagClick = (tag: string): void => { - onTopicsChange( - activeTopics.includes(tag) - ? activeTopics.filter((t) => t !== tag) - : [...activeTopics, tag] - ); - }; - - const handleDropdownBlur = ( - e: FocusEvent<HTMLDivElement>, - closePanel: () => void, - ): void => { - if (e.relatedTarget && !e.currentTarget.contains(e.relatedTarget as Node)) { - closePanel(); - } - }; - - // Arrow-key navigation within an open filter panel. Called from onKeyDown on - // each panel button; walks up to the nearest role="group" to find siblings. - const navigatePanel = (e: ReactKeyboardEvent<HTMLButtonElement>): void => { - if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; - e.preventDefault(); - const panel = e.currentTarget.closest<HTMLElement>('[role="group"]'); - if (!panel) return; - const btns = Array.from(panel.querySelectorAll<HTMLButtonElement>("button")); - const idx = btns.indexOf(e.currentTarget); - if (idx === -1) return; - btns[(e.key === "ArrowDown" ? idx + 1 : idx - 1 + btns.length) % btns.length].focus(); - }; - - const dropdownItemClass = (isActive: boolean): string => cn( - "w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-left transition-colors focus-ring", - isActive - ? "text-primary bg-primary/10" - : "text-dim hover:bg-primary/5 hover:text-foreground dark:hover:text-primary" - ); - - return ( - <div className="mb-8"> - - {/* Mobile / tablet: both dropdowns side by side */} - <div className="flex items-center gap-2 lg:hidden"> - - {/* Difficulty dropdown */} - <div className="relative" ref={difficultyRef} onBlur={(e) => handleDropdownBlur(e, () => setDifficultyOpen(false))}> - <button - ref={difficultyTriggerRef} - type="button" - onClick={() => { setDifficultyOpen((o) => !o); setTagsOpen(false); }} - aria-expanded={difficultyOpen} - aria-controls={difficultyGroupId} - className={cn( - activeDifficulty !== null ? "pill-active" : "pill-inactive", - "px-6 gap-2" - )} - style={activeDifficulty !== null && activeDifficulty in DIFFICULTY_VAR ? difficultyPillStyle(activeDifficulty as Difficulty, true) : allLevelsPillStyle(true)} - > - {activeDifficulty ?? "All Levels"} - <ChevronDown - size={14} - aria-hidden="true" - className={cn("transition-transform duration-200", difficultyOpen && "rotate-180")} - /> - </button> - <div - id={difficultyGroupId} - role="group" - aria-label="Filter by difficulty" - hidden={!difficultyOpen} - className="absolute top-full left-0 z-20 mt-2 min-w-[160px] rounded-xl border border-border bg-[hsl(var(--surface))] p-1.5 shadow-lg" - > - <button type="button" aria-pressed={activeDifficulty === null} - onClick={() => { onDifficultyChange(null); setDifficultyOpen(false); difficultyTriggerRef.current?.focus(); }} - onKeyDown={navigatePanel} - className={dropdownItemClass(activeDifficulty === null)} - > - {activeDifficulty === null ? <Check size={13} aria-hidden="true" /> : <span className="w-[13px] shrink-0" />} - All Levels - </button> - {DIFFICULTIES.map((diff) => { - const isActive = activeDifficulty === diff; - const v = DIFFICULTY_VAR[diff]; - return ( - <button key={diff} type="button" aria-pressed={isActive} - onClick={() => { handleDifficultyClick(diff); setDifficultyOpen(false); difficultyTriggerRef.current?.focus(); }} - onKeyDown={navigatePanel} - className={dropdownItemClass(isActive)} - > - <span className="w-[13px] inline-flex items-center justify-center shrink-0"> - {isActive - ? <Check size={13} aria-hidden="true" /> - : <span - className="h-2.5 w-2.5 rounded-sm" - aria-hidden="true" - style={{ - backgroundColor: `hsl(var(--difficulty-${v}-bg))`, - border: `1px solid hsl(var(--difficulty-${v}-border))`, - }} - /> - } - </span> - {diff} - </button> - ); - })} - </div> - </div> - - {/* Tags dropdown */} - <div className="relative" ref={tagsRef} onBlur={(e) => handleDropdownBlur(e, () => setTagsOpen(false))}> - <button - ref={tagsTriggerRef} - type="button" - onClick={() => { setTagsOpen((o) => !o); setDifficultyOpen(false); }} - aria-expanded={tagsOpen} - aria-controls={tagsGroupId} - className={cn( - activeTopics.length > 0 ? "pill-active" : "pill-inactive", - "px-6 gap-2" - )} - style={allToolsPillStyle(activeTopics.length === 0)} - > - {activeTopics.length === 0 ? "All Tools" : `${activeTopics.length} tool${activeTopics.length !== 1 ? "s" : ""} selected`} - <ChevronDown - size={14} - aria-hidden="true" - className={cn("transition-transform duration-200", tagsOpen && "rotate-180")} - /> - </button> - <div - id={tagsGroupId} - role="group" - aria-label="Filter by technology" - hidden={!tagsOpen} - className="absolute top-full left-0 z-20 mt-2 min-w-[200px] rounded-xl border border-border bg-[hsl(var(--surface))] p-1.5 shadow-lg" - > - <button type="button" aria-pressed={activeTopics.length === 0} - onClick={() => { onTopicsChange([]); setTagsOpen(false); tagsTriggerRef.current?.focus(); }} - onKeyDown={navigatePanel} - className={dropdownItemClass(activeTopics.length === 0)} - > - {activeTopics.length === 0 ? <Check size={13} aria-hidden="true" /> : <span className="w-[13px] shrink-0" />} - All Tools - </button> - {tags.map((tag) => { - const isActive = activeTopics.includes(tag); - return ( - <button key={tag} type="button" aria-pressed={isActive} - onClick={() => handleTagClick(tag)} - onKeyDown={navigatePanel} - className={dropdownItemClass(isActive)} - > - {isActive ? <Check size={13} aria-hidden="true" /> : <span className="w-[13px] shrink-0" />} - {tag} - </button> - ); - })} - <div className="mt-1 border-t border-border pt-1"> - <button - type="button" - onClick={() => { setTagsOpen(false); tagsTriggerRef.current?.focus(); }} - onKeyDown={navigatePanel} - className={dropdownItemClass(false)} - > - Done - </button> - </div> - </div> - </div> - - </div> - - {/* Desktop: two pill rows */} - <div className="hidden lg:block space-y-3"> - - <div role="group" aria-label="Filter by difficulty" className="flex flex-wrap items-center gap-2 pb-3 border-b border-border"> - <button type="button" onClick={() => onDifficultyChange(null)} aria-pressed={activeDifficulty === null} - className={DIFF_PILL_BASE} - style={allLevelsPillStyle(activeDifficulty === null)} - > - All Levels - </button> - {DIFFICULTIES.map((diff) => { - const isActive = activeDifficulty === diff; - return ( - <button key={diff} type="button" onClick={() => handleDifficultyClick(diff)} aria-pressed={isActive} - className={DIFF_PILL_BASE} - style={difficultyPillStyle(diff, isActive)} - > - {diff} - {isActive && <X size={11} aria-hidden="true" />} - </button> - ); - })} - </div> - - <div role="group" aria-label="Filter by technology" className="flex flex-wrap items-center gap-2"> - <button type="button" onClick={() => onTopicsChange([])} aria-pressed={activeTopics.length === 0} - className={DIFF_PILL_BASE} - style={allToolsPillStyle(activeTopics.length === 0)} - > - All Tools - </button> - {tags.map((tag) => { - const isActive = activeTopics.includes(tag); - return ( - <button key={tag} type="button" onClick={() => handleTagClick(tag)} aria-pressed={isActive} - className={cn("filter-pill", isActive ? "pill-active" : "pill-inactive")} - > - {tag} - {isActive && <X size={11} aria-hidden="true" />} - </button> - ); - })} - </div> - - </div> - - </div> - ); -}; diff --git a/src/components/ChallengeHighlights.astro b/src/components/ChallengeHighlights.astro new file mode 100644 index 000000000..f8ab14401 --- /dev/null +++ b/src/components/ChallengeHighlights.astro @@ -0,0 +1,39 @@ +--- +import { LUCIDE_ICONS } from "@/lib/lucide-icons"; + +const rawHighlights = [ + { + icon: "book-open", + title: "Learn by Doing", + desc: "Real-world, hands-on challenges. Broken pipelines, misconfigured systems, and more. All running in your browser via GitHub Codespaces, no setup needed.", + }, + { + icon: "trending-up", + title: "Build Real Skills", + desc: "Build practical, hands-on experience with every challenge. Earn Credly badges and leaderboard points along the way.", + }, + { + icon: "shield", + title: "Open Source First", + desc: "Everything runs on open source tools. Vendor-neutral and free from proprietary lock-in. Open, transparent, and reproducible by anyone.", + }, +]; + +const highlights = rawHighlights.map((h) => ({ ...h, IconComponent: LUCIDE_ICONS[h.icon] })); +--- + +<div class="bg-card py-16 px-6 md:px-16 border-y border-border"> + <div class="mx-auto max-w-6xl grid gap-8 sm:grid-cols-3"> + {highlights.map((h) => ( + <div class="flex gap-4"> + <span class="mt-0.5 shrink-0 text-primary"> + {h.IconComponent && <h.IconComponent width={22} height={22} aria-hidden="true" />} + </span> + <div> + <p class="font-semibold text-foreground">{h.title}</p> + <p class="mt-1 text-sm leading-relaxed text-muted-foreground">{h.desc}</p> + </div> + </div> + ))} + </div> +</div> diff --git a/src/components/ChallengeHighlights.tsx b/src/components/ChallengeHighlights.tsx deleted file mode 100644 index 9e2ebf8bd..000000000 --- a/src/components/ChallengeHighlights.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { type JSX } from "react"; -import { Shield, TrendingUp, BookOpen } from "lucide-react"; - -type Highlight = { - icon: JSX.Element; - title: string; - desc: string; -}; - -const highlights: Highlight[] = [ - { - icon: <BookOpen size={22} aria-hidden="true" />, - title: "Learn by Doing", - desc: "Real-world, hands-on challenges. Broken pipelines, misconfigured systems, and more. All running in your browser via GitHub Codespaces, no setup needed.", - }, - { - icon: <TrendingUp size={22} aria-hidden="true" />, - title: "Build Real Skills", - desc: "Build practical, hands-on experience with every challenge. Earn Credly badges and leaderboard points along the way.", - }, - { - icon: <Shield size={22} aria-hidden="true" />, - title: "Open Source First", - desc: "Everything runs on open source tools. Vendor-neutral and free from proprietary lock-in. Open, transparent, and reproducible by anyone.", - }, -]; - -export const ChallengeHighlights = (): JSX.Element => { - return ( - <div className="bg-card py-16 px-6 md:px-16 border-y border-border"> - <div className="mx-auto max-w-6xl grid gap-8 sm:grid-cols-3"> - {highlights.map((h) => ( - <div key={h.title} className="flex gap-4"> - <span className="mt-0.5 shrink-0 text-primary">{h.icon}</span> - <div> - <p className="font-semibold text-foreground">{h.title}</p> - <p className="mt-1 text-sm leading-relaxed text-muted-foreground">{h.desc}</p> - </div> - </div> - ))} - </div> - </div> - ); -}; diff --git a/src/components/ChallengeShareLinks.astro b/src/components/ChallengeShareLinks.astro new file mode 100644 index 000000000..bf5330494 --- /dev/null +++ b/src/components/ChallengeShareLinks.astro @@ -0,0 +1,73 @@ +--- +// Ported from src/components/ChallengeShareLinks.tsx. All share links are plain +// hrefs, so the component is fully static. Brand SVGs are inline per the +// brand-icon exception (aria-hidden on the svg, aria-label on the link). +import { SITE_NAME } from "@/lib/site"; + +interface Props { + url: string; + levelName: string; +} +const { url, levelName } = Astro.props; + +const encoded = encodeURIComponent(url); +const shareText = `Check out "${levelName}" on ${SITE_NAME}, an open source challenge!`; +const shareTextWithUrl = `${shareText}\n${url}`; + +const linkedinHref = `https://www.linkedin.com/sharing/share-offsite/?url=${encoded}`; +const xHref = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encoded}`; +const blueskyHref = `https://bsky.app/compose?text=${encodeURIComponent(shareTextWithUrl)}`; +const mastodonHref = `https://mastodon.social/share?text=${encodeURIComponent(shareTextWithUrl)}`; +--- + +<div class="flex flex-wrap items-center gap-1 pt-4 border-t border-border"> + <span class="text-xs text-faint mr-1">Know someone who'd enjoy this?</span> + <a + href={linkedinHref} + target="_blank" + rel="noopener noreferrer" + aria-describedby="new-tab-hint" + aria-label="Share on LinkedIn" + class="social-icon-link" + > + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false" class="w-3.5 h-3.5" fill="currentColor"> + <path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"></path> + </svg> + </a> + <a + href={xHref} + target="_blank" + rel="noopener noreferrer" + aria-describedby="new-tab-hint" + aria-label="Share on X / Twitter" + class="social-icon-link" + > + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false" class="w-3.5 h-3.5" fill="currentColor"> + <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"></path> + </svg> + </a> + <a + href={blueskyHref} + target="_blank" + rel="noopener noreferrer" + aria-describedby="new-tab-hint" + aria-label="Share on Bluesky" + class="social-icon-link" + > + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false" class="w-3.5 h-3.5" fill="currentColor"> + <path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.479 0-.689-.139-1.861-.902-2.203-.659-.299-1.664-.621-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8z"></path> + </svg> + </a> + <a + href={mastodonHref} + target="_blank" + rel="noopener noreferrer" + aria-describedby="new-tab-hint" + aria-label="Share on Mastodon" + class="social-icon-link" + > + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false" class="w-3.5 h-3.5" fill="currentColor"> + <path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"></path> + </svg> + </a> +</div> diff --git a/src/components/ChallengeShareLinks.tsx b/src/components/ChallengeShareLinks.tsx deleted file mode 100644 index fb3372467..000000000 --- a/src/components/ChallengeShareLinks.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { type JSX } from "react"; -import { SITE_NAME } from "@/data/constants"; - -type Props = { - url: string; - levelName: string; -}; - -export const ChallengeShareLinks = ({ url, levelName }: Props): JSX.Element => { - const encoded = encodeURIComponent(url); - const shareText = `Check out "${levelName}" on ${SITE_NAME}, an open source challenge!`; - const shareTextWithUrl = `${shareText}\n${url}`; - - const linkedinHref = `https://www.linkedin.com/sharing/share-offsite/?url=${encoded}`; - const xHref = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encoded}`; - const blueskyHref = `https://bsky.app/compose?text=${encodeURIComponent(shareTextWithUrl)}`; - const mastodonHref = `https://mastodon.social/share?text=${encodeURIComponent(shareTextWithUrl)}`; - - return ( - <div className="flex flex-wrap items-center gap-1 pt-4 border-t border-border"> - <span className="text-xs text-faint mr-1">Know someone who'd enjoy this?</span> - <a - href={linkedinHref} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - aria-label="Share on LinkedIn" - className="social-icon-link" - > - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false" className="w-3.5 h-3.5" fill="currentColor"> - <path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" /> - </svg> - </a> - <a - href={xHref} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - aria-label="Share on X / Twitter" - className="social-icon-link" - > - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false" className="w-3.5 h-3.5" fill="currentColor"> - <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /> - </svg> - </a> - <a - href={blueskyHref} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - aria-label="Share on Bluesky" - className="social-icon-link" - > - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false" className="w-3.5 h-3.5" fill="currentColor"> - <path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.479 0-.689-.139-1.861-.902-2.203-.659-.299-1.664-.621-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8z" /> - </svg> - </a> - <a - href={mastodonHref} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - aria-label="Share on Mastodon" - className="social-icon-link" - > - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false" className="w-3.5 h-3.5" fill="currentColor"> - <path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z" /> - </svg> - </a> - </div> - ); -}; diff --git a/src/components/ChallengesFilter.astro b/src/components/ChallengesFilter.astro new file mode 100644 index 000000000..c79180b4e --- /dev/null +++ b/src/components/ChallengesFilter.astro @@ -0,0 +1,630 @@ +--- +import IconClock from "~icons/lucide/clock"; +import IconChevronDown from "~icons/lucide/chevron-down"; +import IconCheck from "~icons/lucide/check"; +import IconX from "~icons/lucide/x"; +import IconArrowRight from "~icons/lucide/arrow-right"; +import AdventureIcon from "@/components/AdventureIcon.astro"; +import { tagToSlug, DIFFICULTIES, type ChallengeEntry } from "@/lib/challenges"; +import { difficultyStyle, DIFFICULTY_VAR, type Difficulty } from "@/lib/difficulty"; +import { stripLinks } from "@/lib/markdown"; + +// Challenge filter. Static markup plus one script. +// +// Every level card is rendered server-side and filtering toggles `hidden` on +// them, rather than shipping the entry data as JSON and re-rendering client +// side. A tag route therefore serves all cards with the non-matching ones +// hidden: it must, because the filter can widen (adding a second tag, or "All +// Tools") without navigating, so the cards the user can reach have to be present. + +interface Props { + entries: ChallengeEntry[]; + tags: string[]; + base: string; + initialTag: string | null; + adventureCount: number; + /** Home context: the page already has a visible heading, so suppress the sr-only ones. */ + embedded?: boolean; + /** Rendered below the unfiltered grid when the page previews fewer adventures than exist. */ + seeAllHref?: string; +} +const { entries, tags, base, initialTag, adventureCount, embedded, seeAllHref } = Astro.props; + +// Initial state mirrors what the script will compute on load, so the SSR markup +// is already correct for a tag route with scripting disabled. +const activeTags = initialTag ? [initialTag] : []; +const hasFilters = activeTags.length > 0; +const matches = (e: ChallengeEntry): boolean => + activeTags.length === 0 || activeTags.some((t) => e.adventureTags.includes(t)); +const matchedCount = entries.filter(matches).length; + +const countText = hasFilters + ? `${matchedCount} ${matchedCount === 1 ? "challenge" : "challenges"}${initialTag ? ` · ${initialTag}` : ""}` + : `${adventureCount} ${adventureCount === 1 ? "adventure" : "adventures"} · ${entries.length} ${entries.length === 1 ? "challenge" : "challenges"}`; + +const DIFF_PILL_BASE = + "filter-pill inline-flex items-center gap-1.5 rounded-full border px-4 py-1.5 min-h-[44px] text-sm font-medium leading-none transition-all duration-200 focus-ring cursor-pointer"; + +const allLevelsPillStyle = (active: boolean): string => + `border-style:solid;border-width:2px;background-color:${active ? "hsl(var(--foreground))" : "transparent"};border-color:${active ? "hsl(var(--foreground))" : "hsl(var(--foreground) / 0.6)"};color:${active ? "hsl(var(--background))" : "hsl(var(--foreground))"}`; + +const allToolsPillStyle = (active: boolean): string => + `border-style:solid;border-width:2px;background-color:transparent;border-color:${active ? "hsl(var(--foreground))" : "hsl(var(--border))"};color:${active ? "hsl(var(--foreground))" : "hsl(var(--text-secondary))"}`; + +const difficultyPillStyle = (d: Difficulty, active: boolean): string => { + const v = DIFFICULTY_VAR[d]; + return `color:hsl(var(--difficulty-text));background-color:hsl(var(--difficulty-${v}-bg));border-style:solid;border-width:2px;border-color:hsl(var(--difficulty-${v}${active ? "" : "-border"}))`; +}; + +const swatchStyle = (d: Difficulty): string => { + const v = DIFFICULTY_VAR[d]; + return `background-color:hsl(var(--difficulty-${v}-bg));border:1px solid hsl(var(--difficulty-${v}-border))`; +}; + +const dropdownItem = + "w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-left transition-colors focus-ring text-dim hover:bg-primary/5 hover:text-foreground dark:hover:text-primary aria-pressed:text-primary aria-pressed:bg-primary/10"; +--- + +<div + data-challenges-filter + data-base={base} + data-initial-tag={initialTag ?? ""} + data-adventure-count={String(adventureCount)} + data-total-challenges={String(entries.length)} +> + <div class="mb-8"> + {/* Mobile / tablet: two dropdowns */} + <div class="flex items-center gap-2 lg:hidden"> + <div data-dropdown="difficulty" class="relative"> + <button + data-dropdown-trigger + type="button" + class="filter-pill px-6 gap-2 pill-inactive" + style={allLevelsPillStyle(true)} + aria-label="Filter by difficulty: All Levels" + aria-expanded="false" + aria-controls="difficulty-group" + > + <span data-difficulty-label>All Levels</span> + <IconChevronDown width={14} height={14} aria-hidden="true" class="transition-transform duration-200" /> + </button> + <div + id="difficulty-group" + role="group" + aria-label="Filter by difficulty" + hidden + class="absolute top-full left-0 z-20 mt-2 min-w-[160px] rounded-xl border border-border bg-[hsl(var(--surface))] p-1.5 shadow-lg" + > + <button type="button" data-difficulty-option="" aria-pressed="true" class={dropdownItem}> + <IconCheck width={13} height={13} aria-hidden="true" data-check /> + All Levels + </button> + { + DIFFICULTIES.map((d) => ( + <button type="button" data-difficulty-option={d} aria-pressed="false" class={dropdownItem}> + <span class="w-[13px] inline-flex items-center justify-center shrink-0"> + <IconCheck width={13} height={13} aria-hidden="true" data-check /> + <span class="h-2.5 w-2.5 rounded-sm" aria-hidden="true" data-swatch style={swatchStyle(d)} /> + </span> + {d} + </button> + )) + } + </div> + </div> + + <div data-dropdown="tags" class="relative"> + <button + data-dropdown-trigger + type="button" + class="filter-pill px-6 gap-2 pill-inactive" + style={hasFilters ? undefined : allToolsPillStyle(true)} + aria-label="Filter by technology: All Tools" + aria-expanded="false" + aria-controls="tags-group" + > + <span data-tags-label>All Tools</span> + <IconChevronDown width={14} height={14} aria-hidden="true" class="transition-transform duration-200" /> + </button> + <div + id="tags-group" + role="group" + aria-label="Filter by technology" + hidden + class="absolute top-full left-0 z-20 mt-2 min-w-[200px] rounded-xl border border-border bg-[hsl(var(--surface))] p-1.5 shadow-lg" + > + <button type="button" data-tag-option="" aria-pressed={hasFilters ? "false" : "true"} class={dropdownItem}> + <IconCheck width={13} height={13} aria-hidden="true" data-check /> + All Tools + </button> + { + tags.map((tag) => ( + <button + type="button" + data-tag-option={tag} + aria-pressed={activeTags.includes(tag) ? "true" : "false"} + class={dropdownItem} + > + <IconCheck width={13} height={13} aria-hidden="true" data-check /> + {tag} + </button> + )) + } + <div class="mt-1 border-t border-border pt-1"> + <button type="button" data-dropdown-done class={dropdownItem}>Done</button> + </div> + </div> + </div> + </div> + + {/* Desktop: two pill rows */} + <div class="hidden lg:block space-y-3"> + {/* APG radiogroup: the group is not focusable, the radios carry roving + tabindex and own the arrow-key handler. */} + <div + role="radiogroup" + aria-label="Filter by difficulty" + class="flex flex-wrap items-center gap-2 pb-3 border-b border-border" + > + <button + type="button" + role="radio" + data-difficulty-radio="" + aria-checked="true" + tabindex="0" + class={DIFF_PILL_BASE} + style={allLevelsPillStyle(true)} + > + All Levels + </button> + { + DIFFICULTIES.map((d) => ( + <button + type="button" + role="radio" + data-difficulty-radio={d} + aria-checked="false" + tabindex="-1" + class={DIFF_PILL_BASE} + style={difficultyPillStyle(d, false)} + > + {d} + <span data-clear hidden> + <IconX width={11} height={11} aria-hidden="true" /> + </span> + </button> + )) + } + </div> + + <div role="group" aria-label="Filter by technology" class="flex flex-wrap items-center gap-2"> + <button + type="button" + data-tag-pill="" + aria-pressed={hasFilters ? "false" : "true"} + class={DIFF_PILL_BASE} + style={allToolsPillStyle(!hasFilters)} + > + All Tools + </button> + { + tags.map((tag) => ( + <button + type="button" + data-tag-pill={tag} + aria-pressed={activeTags.includes(tag) ? "true" : "false"} + class:list={["filter-pill", activeTags.includes(tag) ? "pill-active" : "pill-inactive"]} + > + {tag} + <span data-clear hidden={!activeTags.includes(tag)}> + <IconX width={11} height={11} aria-hidden="true" /> + </span> + </button> + )) + } + </div> + </div> + </div> + + {/* Empty on load: a live region must not announce the state it started in. */} + <span data-live-count aria-live="polite" aria-atomic="true" class="sr-only"></span> + + {!embedded && <h2 data-results-heading class="sr-only">{hasFilters ? "Filtered Challenges" : "All Challenges"}</h2>} + <p data-count class="mb-6 font-sans text-sm font-medium tracking-wide text-muted-foreground">{countText}</p> + + <div data-results="adventures" hidden={hasFilters} class="grid gap-5 md:grid-cols-2 lg:grid-cols-3"> + <slot name="adventures" /> + </div> + { + seeAllHref && ( + <div data-see-all hidden={hasFilters} class="mt-10 flex justify-center"> + <a href={seeAllHref} class="btn-ghost inline-flex items-center gap-2"> + See all adventures + <IconArrowRight width={16} height={16} aria-hidden="true" /> + </a> + </div> + ) + } + + <ul data-results="levels" hidden={!hasFilters} class="animate-fade-up grid gap-5 md:grid-cols-2 lg:grid-cols-3"> + { + entries.map((e) => ( + <li + class="contents" + data-level-card + data-difficulty={e.difficulty} + data-tags={e.adventureTags.map(tagToSlug).join(" ")} + hidden={!matches(e)} + > + <a + href={base + e.url.slice(1)} + aria-label={`${e.name}: ${e.difficulty}${e.isLive ? ", live" : ""}, ${e.adventureTitle}`} + class="group card-glow flex flex-col rounded-xl border border-border bg-[hsl(var(--surface))] p-6 focus-ring" + > + <div class="mb-3 flex items-center justify-between"> + <span + class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 font-mono text-xs font-semibold uppercase tracking-wider transition-colors" + style={difficultyStyle(e.difficulty)} + data-difficulty={e.difficulty} + > + <span class="h-2 w-2 rounded-full bg-current" aria-hidden="true" /> + {e.difficulty} + </span> + {e.isLive && ( + <span + data-live-pill + class="inline-flex items-center gap-1.5 rounded-sm bg-primary px-2.5 py-1 font-mono text-xs uppercase tracking-wider text-primary-foreground" + > + <span class="relative flex h-1.5 w-1.5" aria-hidden="true"> + <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary-foreground opacity-75" /> + <span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-primary-foreground" /> + </span> + Live + </span> + )} + </div> + + <h3 class="text-lg font-semibold text-foreground transition-colors group-hover:text-primary"> + {e.name} + {e.adventureIcon && ( + <span class="ml-1 inline-flex items-center align-middle text-muted-foreground"> + <AdventureIcon icon={e.adventureIcon} size={16} /> + </span> + )} + </h3> + + <ul role="list" class="mt-3 space-y-1.5"> + {e.learnings.map((l) => ( + <li class="flex items-start gap-2 text-sm text-muted-foreground"> + <span class="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-primary" aria-hidden="true" /> + <span class="md-inline min-w-0" set:html={stripLinks(l)} /> + </li> + ))} + </ul> + + <div class="mt-auto flex flex-wrap items-center justify-between gap-1.5 pt-4"> + <div class="flex items-center gap-1.5"> + <span class="font-mono text-xs text-muted-foreground">Challenge</span> + {e.estimatedTime && ( + <span class="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 font-mono text-xs text-faint"> + <IconClock width={10} height={10} aria-hidden="true" /> + {e.estimatedTime} + </span> + )} + </div> + <span class="rounded-sm border border-border px-2 py-0.5 text-xs text-faint">{e.adventureTitle}</span> + </div> + </a> + </li> + )) + } + </ul> + + <p data-empty hidden={!(hasFilters && matchedCount === 0)} class="mt-6 text-dim"> + No challenges match these filters. + </p> +</div> + +<script> + import { tagToSlug, DIFFICULTIES } from "@/lib/challenges"; + import { DIFFICULTY_VAR, type Difficulty } from "@/lib/difficulty"; + + type State = { tags: string[]; difficulty: string | null; touched: boolean }; + + function initChallengesFilter(): void { + const root = document.querySelector<HTMLElement>("[data-challenges-filter]"); + if (!root) return; + + const base = root.dataset.base ?? "/"; + const initialTag = root.dataset.initialTag || null; + const adventureCount = Number(root.dataset.adventureCount ?? "0"); + const totalChallenges = Number(root.dataset.totalChallenges ?? "0"); + + const cards = Array.from(root.querySelectorAll<HTMLElement>("[data-level-card]")); + const levelGrid = root.querySelector<HTMLElement>('[data-results="levels"]')!; + const advGrid = root.querySelector<HTMLElement>('[data-results="adventures"]')!; + const seeAll = root.querySelector<HTMLElement>("[data-see-all]"); + const countEl = root.querySelector<HTMLElement>("[data-count]")!; + const headingEl = root.querySelector<HTMLElement>("[data-results-heading]"); + const emptyEl = root.querySelector<HTMLElement>("[data-empty]")!; + const liveEl = root.querySelector<HTMLElement>("[data-live-count]")!; + + const tagPills = Array.from(root.querySelectorAll<HTMLButtonElement>("[data-tag-pill]")); + const tagOptions = Array.from(root.querySelectorAll<HTMLButtonElement>("[data-tag-option]")); + const diffRadios = Array.from(root.querySelectorAll<HTMLButtonElement>("[data-difficulty-radio]")); + const diffOptions = Array.from(root.querySelectorAll<HTMLButtonElement>("[data-difficulty-option]")); + const allTagNames = tagPills.map((b) => b.dataset.tagPill!).filter(Boolean); + + const state: State = { tags: initialTag ? [initialTag] : [], difficulty: null, touched: false }; + + // ── URL ──────────────────────────────────────────────────────────────── + const readUrl = (): void => { + const params = new URLSearchParams(window.location.search); + const topics = params.get("topics"); + if (topics !== null) { + const slugs = topics.split(",").filter(Boolean); + state.tags = allTagNames.filter((t) => slugs.includes(tagToSlug(t))); + } + const diff = params.get("difficulty"); + if (diff && (DIFFICULTIES as readonly string[]).includes(diff)) state.difficulty = diff; + // `touched` stays false here on purpose. Arriving at a filtered URL is not + // an interaction, and a live region that announces the state a page loaded + // in is just noise. Only the action handlers set it. + }; + + const syncUrl = (): void => { + const params = new URLSearchParams(window.location.search); + if (state.tags.length) params.set("topics", state.tags.map(tagToSlug).join(",")); + else params.delete("topics"); + if (state.difficulty) params.set("difficulty", state.difficulty); + else params.delete("difficulty"); + const qs = params.toString(); + // On a /challenges/<tag>/ route, clearing all tags must drop the path + // segment or the tag re-seeds the filter on reload, share or back. + // replaceState preserves scroll position. + const path = + initialTag !== null && state.tags.length === 0 ? `${base}challenges/` : window.location.pathname; + window.history.replaceState(null, "", path + (qs ? `?${qs}` : "")); + }; + + // ── Render ───────────────────────────────────────────────────────────── + const plural = (n: number, word: string): string => `${n} ${n === 1 ? word : `${word}s`}`; + + const apply = (): void => { + const activeSlugs = state.tags.map(tagToSlug); + let shown = 0; + + for (const card of cards) { + const cardTags = (card.dataset.tags ?? "").split(" "); + const tagOk = activeSlugs.length === 0 || activeSlugs.some((t) => cardTags.includes(t)); + const diffOk = !state.difficulty || card.dataset.difficulty === state.difficulty; + const visible = tagOk && diffOk; + card.hidden = !visible; + if (visible) shown++; + } + + const filtering = state.tags.length > 0 || state.difficulty !== null; + + advGrid.hidden = filtering; + if (seeAll) seeAll.hidden = filtering; + levelGrid.hidden = !filtering; + emptyEl.hidden = !(filtering && shown === 0); + + let text: string; + if (filtering) { + text = plural(shown, "challenge"); + if (state.difficulty) text += ` · ${state.difficulty}`; + if (state.tags.length) text += ` · ${state.tags.join(", ")}`; + } else { + text = `${plural(adventureCount, "adventure")} · ${plural(totalChallenges, "challenge")}`; + } + countEl.textContent = text; + if (headingEl) headingEl.textContent = filtering ? "Filtered Challenges" : "All Challenges"; + + // Only speak once the user has actually changed something. + liveEl.textContent = !state.touched + ? "" + : filtering + ? `Showing ${text}` + : `Filters cleared, showing ${text}`; + + syncControls(); + }; + + const syncControls = (): void => { + for (const pill of tagPills) { + const tag = pill.dataset.tagPill!; + const on = tag ? state.tags.includes(tag) : state.tags.length === 0; + pill.setAttribute("aria-pressed", String(on)); + pill.querySelector<HTMLElement>("[data-clear]")?.toggleAttribute("hidden", !on); + if (tag) pill.className = `filter-pill ${on ? "pill-active" : "pill-inactive"}`; + else pill.setAttribute("style", allToolsPill(on)); + } + for (const opt of tagOptions) { + const tag = opt.dataset.tagOption!; + const on = tag ? state.tags.includes(tag) : state.tags.length === 0; + opt.setAttribute("aria-pressed", String(on)); + opt.querySelector<HTMLElement>("[data-check]")?.toggleAttribute("hidden", !on); + } + for (const radio of diffRadios) { + const d = radio.dataset.difficultyRadio!; + const on = d ? state.difficulty === d : state.difficulty === null; + radio.setAttribute("aria-checked", String(on)); + radio.tabIndex = on ? 0 : -1; + radio.querySelector<HTMLElement>("[data-clear]")?.toggleAttribute("hidden", !on); + radio.setAttribute("style", d ? diffPill(d as Difficulty, on) : allLevelsPill(on)); + } + for (const opt of diffOptions) { + const d = opt.dataset.difficultyOption!; + const on = d ? state.difficulty === d : state.difficulty === null; + opt.setAttribute("aria-pressed", String(on)); + opt.querySelector<HTMLElement>("[data-check]")?.toggleAttribute("hidden", !on); + opt.querySelector<HTMLElement>("[data-swatch]")?.toggleAttribute("hidden", on); + } + + const diffLabel = root.querySelector<HTMLElement>("[data-difficulty-label]"); + if (diffLabel) diffLabel.textContent = state.difficulty ?? "All Levels"; + const diffTrigger = root.querySelector<HTMLButtonElement>('[aria-controls="difficulty-group"]'); + diffTrigger?.setAttribute("aria-label", `Filter by difficulty: ${state.difficulty ?? "All Levels"}`); + if (diffTrigger) + diffTrigger.setAttribute( + "style", + state.difficulty ? diffPill(state.difficulty as Difficulty, true) : allLevelsPill(true), + ); + + const tagsLabel = root.querySelector<HTMLElement>("[data-tags-label]"); + const tagsText = + state.tags.length === 0 ? "All Tools" : `${state.tags.length} tool${state.tags.length !== 1 ? "s" : ""} selected`; + if (tagsLabel) tagsLabel.textContent = tagsText; + const tagsTrigger = root.querySelector<HTMLButtonElement>('[aria-controls="tags-group"]'); + tagsTrigger?.setAttribute("aria-label", `Filter by technology: ${tagsText}`); + }; + + const allLevelsPill = (on: boolean): string => + `border-style:solid;border-width:2px;background-color:${on ? "hsl(var(--foreground))" : "transparent"};border-color:${on ? "hsl(var(--foreground))" : "hsl(var(--foreground) / 0.6)"};color:${on ? "hsl(var(--background))" : "hsl(var(--foreground))"}`; + const allToolsPill = (on: boolean): string => + `border-style:solid;border-width:2px;background-color:transparent;border-color:${on ? "hsl(var(--foreground))" : "hsl(var(--border))"};color:${on ? "hsl(var(--foreground))" : "hsl(var(--text-secondary))"}`; + const diffPill = (d: Difficulty, on: boolean): string => { + const v = DIFFICULTY_VAR[d]; + return `color:hsl(var(--difficulty-text));background-color:hsl(var(--difficulty-${v}-bg));border-style:solid;border-width:2px;border-color:hsl(var(--difficulty-${v}${on ? "" : "-border"}))`; + }; + + // ── Actions ──────────────────────────────────────────────────────────── + function setDifficulty(d: string | null, toggle: boolean): void { + state.touched = true; + state.difficulty = toggle && state.difficulty === d ? null : d; + apply(); + syncUrl(); + } + function toggleTag(tag: string): void { + state.touched = true; + state.tags = state.tags.includes(tag) ? state.tags.filter((t) => t !== tag) : [...state.tags, tag]; + apply(); + syncUrl(); + } + function clearTags(): void { + state.touched = true; + state.tags = []; + apply(); + syncUrl(); + } + + // ── Dropdowns ────────────────────────────────────────────────────────── + const dropdowns = Array.from(root.querySelectorAll<HTMLElement>("[data-dropdown]")); + const panelOf = (d: HTMLElement) => d.querySelector<HTMLElement>('[role="group"]')!; + const triggerOf = (d: HTMLElement) => d.querySelector<HTMLButtonElement>("[data-dropdown-trigger]")!; + + function setOpen(d: HTMLElement, open: boolean): void { + panelOf(d).hidden = !open; + triggerOf(d).setAttribute("aria-expanded", String(open)); + triggerOf(d) + .querySelector("svg") + ?.classList.toggle("rotate-180", open); + } + const isOpen = (d: HTMLElement): boolean => triggerOf(d).getAttribute("aria-expanded") === "true"; + const closeAll = (except?: HTMLElement): void => + dropdowns.forEach((d) => d !== except && setOpen(d, false)); + + for (const d of dropdowns) { + triggerOf(d).addEventListener("click", () => { + const next = !isOpen(d); + closeAll(d); + setOpen(d, next); + }); + // Close when focus leaves the wrapper entirely. A null relatedTarget + // (focus went nowhere) is left to the outside-mousedown handler, and this + // must not move focus: the user has already sent it elsewhere. + d.addEventListener("focusout", (event) => { + const next = (event as FocusEvent).relatedTarget as Node | null; + if (next && !d.contains(next)) setOpen(d, false); + }); + d.querySelector<HTMLButtonElement>("[data-dropdown-done]")?.addEventListener("click", () => { + setOpen(d, false); + triggerOf(d).focus(); + }); + } + + const onMousedown = (event: MouseEvent) => { + const t = event.target as Node; + dropdowns.forEach((d) => !d.contains(t) && setOpen(d, false)); + }; + + const onKeydown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + const open = dropdowns.find(isOpen); + if (!open) return; + setOpen(open, false); + triggerOf(open).focus(); + }; + + document.addEventListener("mousedown", onMousedown); + document.addEventListener("keydown", onKeydown); + + // ── Wiring ───────────────────────────────────────────────────────────── + for (const pill of tagPills) { + const tag = pill.dataset.tagPill!; + pill.addEventListener("click", () => (tag ? toggleTag(tag) : clearTags())); + } + for (const opt of tagOptions) { + const tag = opt.dataset.tagOption!; + opt.addEventListener("click", () => { + if (tag) toggleTag(tag); + else { + clearTags(); + const d = opt.closest<HTMLElement>("[data-dropdown]")!; + setOpen(d, false); + triggerOf(d).focus(); + } + }); + } + for (const radio of diffRadios) { + const d = radio.dataset.difficultyRadio || null; + radio.addEventListener("click", () => setDifficulty(d, d !== null)); + radio.addEventListener("keydown", (event) => { + const key = (event as KeyboardEvent).key; + if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(key)) return; + event.preventDefault(); + const i = diffRadios.indexOf(radio); + const dir = key === "ArrowRight" || key === "ArrowDown" ? 1 : -1; + const next = diffRadios[(i + dir + diffRadios.length) % diffRadios.length]; + next.focus(); + next.click(); + }); + } + for (const opt of diffOptions) { + const d = opt.dataset.difficultyOption || null; + opt.addEventListener("click", () => { + setDifficulty(d, false); + const dd = opt.closest<HTMLElement>("[data-dropdown]")!; + setOpen(dd, false); + triggerOf(dd).focus(); + }); + opt.addEventListener("keydown", (event) => { + const key = (event as KeyboardEvent).key; + if (key !== "ArrowDown" && key !== "ArrowUp") return; + event.preventDefault(); + const panel = opt.closest<HTMLElement>('[role="group"]')!; + const btns = Array.from(panel.querySelectorAll<HTMLButtonElement>("button")); + const i = btns.indexOf(opt); + btns[(i + (key === "ArrowDown" ? 1 : -1) + btns.length) % btns.length].focus(); + }); + } + + readUrl(); + apply(); + + filterTeardown = () => { + document.removeEventListener("mousedown", onMousedown); + document.removeEventListener("keydown", onKeydown); + }; + } + + // Module-scope teardown; null when the filter is not mounted on this page. + let filterTeardown: (() => void) | null = null; + + document.addEventListener("astro:page-load", initChallengesFilter); + document.addEventListener("astro:before-swap", () => { + filterTeardown?.(); + filterTeardown = null; + }); +</script> diff --git a/src/components/ChallengesGrid.tsx b/src/components/ChallengesGrid.tsx deleted file mode 100644 index c7f90dc75..000000000 --- a/src/components/ChallengesGrid.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useState, type JSX } from "react"; -import { Link } from "react-router"; -import { ArrowRight } from "lucide-react"; -import { ADVENTURE_SUMMARIES, SUMMARY_TAGS } from "@/data/adventures/summaries"; -import { getLevelSummariesByFilters, ALL_LEVEL_SUMMARIES } from "@/data/adventures/filter-utils"; -import { AdventureCard } from "@/components/AdventureCard"; -import { FilteredLevelCard } from "@/components/FilteredLevelCard"; -import { SectionLabel } from "@/components/SectionLabel"; -import { StarterNudge } from "@/components/StarterNudge"; -import { ChallengeFilters, type Difficulty } from "@/components/ChallengeFilters"; - -type ChallengesGridProps = { - /** When set, limits the number of adventure cards shown and renders a "See all" link to /challenges if there are more. */ - limit?: number; -}; - -export const ChallengesGrid = ({ limit }: ChallengesGridProps = {}): JSX.Element => { - const [activeTopics, setActiveTopics] = useState<string[]>([]); - const [activeDifficulty, setActiveDifficulty] = useState<Difficulty | null>(null); - const [hasFiltered, setHasFiltered] = useState(false); - - const isFiltered = activeTopics.length > 0 || activeDifficulty !== null; - const filteredLevels = isFiltered ? getLevelSummariesByFilters(activeTopics, activeDifficulty) : []; - - const visibleAdventures = limit !== undefined ? ADVENTURE_SUMMARIES.slice(0, limit) : ADVENTURE_SUMMARIES; - const hasMore = limit !== undefined && ADVENTURE_SUMMARIES.length > limit; - - const handleDifficultyChange = (diff: Difficulty | null): void => { - setHasFiltered(true); - setActiveDifficulty(diff); - }; - - const handleTopicsChange = (topics: string[]): void => { - setHasFiltered(true); - setActiveTopics(topics); - }; - - const filterKey = activeTopics.join(",") + (activeDifficulty ?? ""); - - return ( - <section id="challenges" aria-labelledby="challenges-heading" className="py-24 px-6 md:px-16"> - <div className="mx-auto max-w-6xl"> - <div> - <SectionLabel>Adventures</SectionLabel> - <h2 id="challenges-heading" className="mb-6 text-3xl font-bold text-primary md:text-4xl"> - Choose Your Adventure - </h2> - - <StarterNudge /> - - <ChallengeFilters - activeTopics={activeTopics} - activeDifficulty={activeDifficulty} - tags={SUMMARY_TAGS} - onDifficultyChange={handleDifficultyChange} - onTopicsChange={handleTopicsChange} - /> - - {/* Live region */} - <p aria-live="polite" aria-atomic="true" className="sr-only"> - {isFiltered - ? `Showing ${filteredLevels.length} ${filteredLevels.length === 1 ? "challenge" : "challenges"}${activeDifficulty ? ` · ${activeDifficulty}` : ""}${activeTopics.length > 0 ? ` · ${activeTopics.join(", ")}` : ""}` - : hasFiltered - ? `Filters cleared, showing ${ADVENTURE_SUMMARIES.length} ${ADVENTURE_SUMMARIES.length === 1 ? "adventure" : "adventures"} · ${ALL_LEVEL_SUMMARIES.length} ${ALL_LEVEL_SUMMARIES.length === 1 ? "challenge" : "challenges"}` - : ""} - </p> - - {isFiltered ? ( - <> - <p className="animate-fade-up mb-6 font-sans text-sm font-medium tracking-wide text-muted-foreground"> - {filteredLevels.length} {filteredLevels.length === 1 ? "challenge" : "challenges"} - {activeDifficulty && ` · ${activeDifficulty}`} - {activeTopics.length > 0 && ` · ${activeTopics.join(", ")}`} - </p> - <div key={filterKey} className="animate-fade-up grid gap-5 md:grid-cols-2 lg:grid-cols-3"> - {filteredLevels.map(({ level, adventureId, adventureTitle, isLive, adventureIcon }) => ( - <FilteredLevelCard - key={`${adventureId}-${level.id}`} - level={level} - adventureId={adventureId} - adventureTitle={adventureTitle} - isLive={isLive} - adventureIcon={adventureIcon} - /> - ))} - </div> - </> - ) : ( - <> - <p className="mb-6 font-sans text-sm font-medium tracking-wide text-muted-foreground"> - {ADVENTURE_SUMMARIES.length} {ADVENTURE_SUMMARIES.length === 1 ? "adventure" : "adventures"} · {ALL_LEVEL_SUMMARIES.length} {ALL_LEVEL_SUMMARIES.length === 1 ? "challenge" : "challenges"} - </p> - <div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3"> - {visibleAdventures.map((adventure) => ( - <AdventureCard key={adventure.id} adventure={adventure} /> - ))} - </div> - {hasMore && ( - <div className="mt-10 flex justify-center"> - <Link to="/challenges/" className="btn-ghost inline-flex items-center gap-2"> - See all adventures - <ArrowRight className="h-4 w-4" aria-hidden="true" /> - </Link> - </div> - )} - </> - )} - </div> - </div> - </section> - ); -}; diff --git a/src/components/CodeBlock.tsx b/src/components/CodeBlock.tsx deleted file mode 100644 index 60b836843..000000000 --- a/src/components/CodeBlock.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useState, useRef, useEffect, type JSX } from "react"; -import { Copy, Check } from "lucide-react"; - -type CodeBlockProps = { - language: string; - title?: string; - code: string; -}; - -export const CodeBlock = ({ language, title, code }: CodeBlockProps): JSX.Element => { - const [copied, setCopied] = useState(false); - const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); - - useEffect(() => (): void => { - if (copyTimeoutRef.current !== null) clearTimeout(copyTimeoutRef.current); - }, []); - - const handleCopy = (): void => { - navigator.clipboard?.writeText(code).then(() => { - setCopied(true); - if (copyTimeoutRef.current !== null) clearTimeout(copyTimeoutRef.current); - copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500); - }).catch(() => { - // writeText can fail when the document loses focus - }); - }; - - return ( - <div> - <div className="code-block-header"> - <span className="code-lang-label" aria-hidden="true"> - {title ?? language} - </span> - <button - type="button" - className="code-header-btn" - onClick={handleCopy} - aria-label={copied ? "Code copied" : "Copy code"} - > - {copied ? ( - <Check size={12} aria-hidden="true" /> - ) : ( - <Copy size={12} aria-hidden="true" /> - )} - {copied ? "Copied" : "Copy"} - </button> - </div> - <div className="md-content code-block-body"> - <div className="md-pre-group"> - {/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- makes scrollable code block keyboard-reachable per WCAG 2.1 SC 2.1.1 */} - <pre tabIndex={0} aria-label={title ?? `${language} code block`}> - <code>{code}</code> - </pre> - </div> - </div> - <span aria-live="polite" aria-atomic="true" className="sr-only"> - {copied ? "Code copied to clipboard" : ""} - </span> - </div> - ); -}; diff --git a/src/components/CodespacesButton.astro b/src/components/CodespacesButton.astro new file mode 100644 index 000000000..8cb659f38 --- /dev/null +++ b/src/components/CodespacesButton.astro @@ -0,0 +1,23 @@ +--- +// Ported from src/components/CodespacesButton.tsx. +import IconExternalLink from "~icons/lucide/external-link"; + +interface Props { + href: string; + fullWidth?: boolean; +} +const { href, fullWidth = false } = Astro.props; +--- + +<a + href={href} + target="_blank" + rel="noopener noreferrer" + aria-describedby="new-tab-hint" + class={fullWidth ? "btn-primary w-full justify-center" : "btn-primary w-fit"} +> + Open in Codespaces <IconExternalLink width={14} height={14} aria-hidden="true" /> +</a> +<p class:list={["mt-2.5 text-xs text-faint font-mono", fullWidth && "text-center"]}> + Free GitHub account required +</p> diff --git a/src/components/CodespacesButton.tsx b/src/components/CodespacesButton.tsx deleted file mode 100644 index 2fa28a7a2..000000000 --- a/src/components/CodespacesButton.tsx +++ /dev/null @@ -1,23 +0,0 @@ -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 => ( - <> - <a - href={href} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - className={fullWidth ? "btn-primary w-full justify-center" : "btn-primary w-fit"} - > - Open in Codespaces <ExternalLink size={14} aria-hidden="true" /> - </a> - <p className={`mt-2.5 text-xs text-faint font-mono${fullWidth ? " text-center" : ""}`}> - Free GitHub account required - </p> - </> -); diff --git a/src/components/CollapsibleSection.astro b/src/components/CollapsibleSection.astro new file mode 100644 index 000000000..332ae675a --- /dev/null +++ b/src/components/CollapsibleSection.astro @@ -0,0 +1,31 @@ +--- +// Ported from src/components/CollapsibleSection.tsx. Uses a native +// <details>/<summary> disclosure, so expand/collapse works without JS. +import IconChevronDown from "~icons/lucide/chevron-down"; + +interface Props { + id: string; + title: string; + defaultOpen?: boolean; + headingLevel?: 2 | 3 | 4; +} +const { id, title, defaultOpen = true, headingLevel = 2 } = Astro.props; +const Heading = `h${headingLevel}` as "h2" | "h3" | "h4"; +--- + +<details id={id} open={defaultOpen} class="card-glow group mb-6 scroll-mt-28 rounded-lg"> + <summary class="flex cursor-pointer list-none items-center gap-3 rounded-t-lg border border-border bg-[hsl(var(--surface))] px-4 py-3 group-open:rounded-b-none group-open:border-b-0 rounded-b-lg focus-ring [&::-webkit-details-marker]:hidden"> + <Heading class="font-sans text-sm font-semibold tracking-wide text-primary flex-1 m-0"> + {title} + </Heading> + <IconChevronDown + width={18} + height={18} + class="shrink-0 text-faint transition-transform group-open:rotate-180" + aria-hidden="true" + /> + </summary> + <div class="rounded-b-lg border border-t-0 border-border bg-[hsl(var(--surface))] px-4 py-5 text-sm"> + <slot /> + </div> +</details> diff --git a/src/components/CollapsibleSection.tsx b/src/components/CollapsibleSection.tsx deleted file mode 100644 index 6c7214d84..000000000 --- a/src/components/CollapsibleSection.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { JSX, ReactNode } from "react"; -import { ChevronDown } from "lucide-react"; - -type CollapsibleSectionProps = { - id: string; - title: string; - children: ReactNode; - defaultOpen?: boolean; - headingLevel?: 2 | 3 | 4; -}; - -export const CollapsibleSection = ({ - id, - title, - children, - defaultOpen = true, - headingLevel = 2, -}: CollapsibleSectionProps): JSX.Element => { - const Heading = `h${headingLevel}` as "h2" | "h3" | "h4"; - return ( - <details - id={id} - open={defaultOpen} - className="card-glow group mb-6 scroll-mt-28 rounded-lg" - > - <summary className="flex cursor-pointer list-none items-center gap-3 rounded-t-lg border border-border bg-[hsl(var(--surface))] px-4 py-3 group-open:rounded-b-none group-open:border-b-0 rounded-b-lg focus-ring [&::-webkit-details-marker]:hidden"> - <Heading className="font-sans text-sm font-semibold tracking-wide text-primary flex-1 m-0"> - {title} - </Heading> - <ChevronDown - size={18} - className="shrink-0 text-faint transition-transform group-open:rotate-180" - aria-hidden="true" - /> - </summary> - <div className="rounded-b-lg border border-t-0 border-border bg-[hsl(var(--surface))] px-4 py-5 text-sm"> - {children} - </div> - </details> - ); -}; diff --git a/src/components/CommunityLeaders.astro b/src/components/CommunityLeaders.astro new file mode 100644 index 000000000..75dc35ff3 --- /dev/null +++ b/src/components/CommunityLeaders.astro @@ -0,0 +1,76 @@ +--- +import { LUCIDE_ICONS } from "@/lib/lucide-icons"; +import communityLeadersData from "@/data/community-leaders.json"; +import AvatarLink from "@/components/AvatarLink.astro"; + +type LeaderUser = { username: string; avatarUrl: string; count: number }; +type LeaderSection = { id: string; title: string; users: LeaderUser[] }; + +interface Props { + /** Which section IDs to show. Omit to show all. */ + sections?: string[]; + /** Max users to show per section. Omit to show all. */ + limit?: number; +} + +const { sections: sectionFilter, limit } = Astro.props; + +const SECTION_ICON_NAMES: Record<string, string> = { + "top-contributors": "trophy", + "top-challenge-solvers": "target", + "challenge-rockstars": "star", + "challenge-grand-builders": "building-2", + "challenge-builders": "wrench", + "most-liked": "heart", + "most-replies": "message-circle", + "most-supportive": "hand-heart", +}; + +const ALL_SECTIONS = communityLeadersData.sections as LeaderSection[]; + +const visibleSections = ( + sectionFilter + ? sectionFilter + .map((id) => ALL_SECTIONS.find((s) => s.id === id)) + .filter((s): s is LeaderSection => s !== undefined) + : ALL_SECTIONS +) + .map((s) => (limit !== undefined ? { ...s, users: s.users.slice(0, limit) } : s)) + .map((s) => ({ ...s, IconComponent: LUCIDE_ICONS[SECTION_ICON_NAMES[s.id]] })); +--- + +<div class="rounded-xl border border-border bg-[hsl(var(--surface))] p-5"> + <h3 class="font-sans text-base font-semibold text-foreground mb-5">Community Leaders</h3> + <div class="space-y-5"> + {visibleSections.map((section) => ( + <div class="pb-5 border-b border-border last:border-b-0 last:pb-0"> + <div> + <h4 class="text-sm font-semibold text-foreground mb-3 flex items-center gap-2"> + {section.IconComponent && ( + <span class="text-primary"> + <section.IconComponent width={14} height={14} aria-hidden="true" /> + </span> + )} + {section.title} + </h4> + <ol class="space-y-2.5" aria-label={section.title}> + {section.users.map((user, i) => ( + <li class="flex items-center gap-3"> + <span class="font-mono text-xs text-faint w-4 shrink-0 text-right" aria-hidden="true">{i + 1}</span> + <AvatarLink + username={user.username} + avatarUrl={user.avatarUrl} + size={28} + class="text-sm font-medium text-foreground min-w-0 flex-1" + /> + <span class="text-xs font-mono text-dim tabular-nums shrink-0" aria-label={`${user.count} contributions`}> + {user.count} + </span> + </li> + ))} + </ol> + </div> + </div> + ))} + </div> +</div> diff --git a/src/components/CommunityLeaders.tsx b/src/components/CommunityLeaders.tsx deleted file mode 100644 index 89f1cd3d7..000000000 --- a/src/components/CommunityLeaders.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import type { JSX } from "react"; -import { Trophy, Target, Building2, Wrench, Heart, MessageCircle, HandHeart, Star } from "lucide-react"; -import communityLeadersData from "@/data/community-leaders.json"; -import { AvatarLink } from "@/components/AvatarLink"; - -type LeaderUser = { - username: string; - avatarUrl: string; - count: number; -}; - -type LeaderSection = { - id: string; - title: string; - users: LeaderUser[]; -}; - -type CommunityLeadersProps = { - /** Which section IDs to show. Omit to show all. */ - sections?: string[]; - /** Max users to show per section. Omit to show all. */ - limit?: number; -}; - -const SECTION_ICONS: Record<string, JSX.Element> = { - "top-contributors": <Trophy size={14} aria-hidden="true" />, - "top-challenge-solvers": <Target size={14} aria-hidden="true" />, - "challenge-rockstars": <Star size={14} aria-hidden="true" />, - "challenge-grand-builders": <Building2 size={14} aria-hidden="true" />, - "challenge-builders": <Wrench size={14} aria-hidden="true" />, - "most-liked": <Heart size={14} aria-hidden="true" />, - "most-replies": <MessageCircle size={14} aria-hidden="true" />, - "most-supportive": <HandHeart size={14} aria-hidden="true" />, -}; - -const ALL_SECTIONS: LeaderSection[] = communityLeadersData.sections; - -const LeaderRow = ({ user, rank }: { user: LeaderUser; rank: number }): JSX.Element => ( - <li className="flex items-center gap-3"> - <span - className="font-mono text-xs text-faint w-4 shrink-0 text-right" - aria-hidden="true" - > - {rank} - </span> - <AvatarLink - username={user.username} - avatarUrl={user.avatarUrl} - size={28} - className="text-sm font-medium text-foreground min-w-0 flex-1" - /> - <span - className="text-xs font-mono text-dim tabular-nums shrink-0" - aria-label={`${user.count} contributions`} - > - {user.count} - </span> - </li> -); - -const LeaderCategory = ({ section }: { section: LeaderSection }): JSX.Element => ( - <div> - <h4 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2"> - <span className="text-primary">{SECTION_ICONS[section.id]}</span> - {section.title} - </h4> - <ol className="space-y-2.5" aria-label={section.title}> - {section.users.map((user, i) => ( - <LeaderRow key={user.username} user={user} rank={i + 1} /> - ))} - </ol> - </div> -); - -export const CommunityLeaders = ({ - sections: sectionFilter, - limit, -}: CommunityLeadersProps): JSX.Element => { - const visibleSections = (sectionFilter - ? sectionFilter - .map((id) => ALL_SECTIONS.find((s) => s.id === id)) - .filter((s): s is LeaderSection => s !== undefined) - : ALL_SECTIONS - ).map((s) => - limit !== undefined ? { ...s, users: s.users.slice(0, limit) } : s - ); - - return ( - <div - className="rounded-xl border border-border bg-[hsl(var(--surface))] p-5" - > - <h3 className="font-sans text-base font-semibold text-foreground mb-5"> - Community Leaders - </h3> - <div className="space-y-5"> - {visibleSections.map((section) => ( - <div - key={section.id} - className="pb-5 border-b border-border last:border-b-0 last:pb-0" - > - <LeaderCategory section={section} /> - </div> - ))} - </div> - </div> - ); -}; diff --git a/src/components/CommunitySection.astro b/src/components/CommunitySection.astro new file mode 100644 index 000000000..f8d46f31b --- /dev/null +++ b/src/components/CommunitySection.astro @@ -0,0 +1,76 @@ +--- +import { LUCIDE_ICONS } from "@/lib/lucide-icons"; +import { COMMUNITY_URL } from "@/lib/site"; +import SectionLabel from "@/components/SectionLabel.astro"; + +const rawCards = [ + { + icon: "megaphone", + title: "Community Voices", + desc: "Share tutorials, showcase projects, post open source news, and write about what you have learned. The home for community-created content.", + cta: "Share Something", + href: `${COMMUNITY_URL}/c/community-voices/38`, + }, + { + icon: "circle-help", + title: "Q&A", + desc: "Stuck on a technical problem or not sure where to start? Post a question and get answers from the community. No question is too basic.", + cta: "Ask a Question", + href: `${COMMUNITY_URL}/c/general/q-a/10`, + }, + { + icon: "user-plus", + title: "Introduce Yourself", + desc: "New here? Tell us about yourself. Share your role, what you're building or learning, and one thing you want to get out of the community.", + cta: "Say Hello", + href: `${COMMUNITY_URL}/c/general/introductions/18`, + }, + { + icon: "calendar-days", + title: "Events & Meetups", + desc: "Find upcoming events, add local meetups to the community calendar, and connect with members in your city.", + cta: "See Upcoming Events", + href: `${COMMUNITY_URL}/c/events-and-talks/12`, + }, +]; + +const ExternalLinkIcon = LUCIDE_ICONS["external-link"]; +const cards = rawCards.map((c) => ({ ...c, IconComponent: LUCIDE_ICONS[c.icon] })); + +const hasAside = Astro.slots.has("aside"); +--- + +<section aria-labelledby="community-section-heading" class="py-24 px-6 md:px-16"> + <div class="mx-auto max-w-6xl"> + <div class={hasAside ? "grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-12" : ""}> + <div> + <SectionLabel>community</SectionLabel> + <h2 id="community-section-heading" class="mb-6 text-3xl font-bold text-primary md:text-4xl">Get Involved</h2> + <p class="mb-12 max-w-xl text-dim leading-relaxed"> + The community is where open source comes alive. Share what you know, ask for help, meet the people behind the projects, and find events near you. + </p> + <div class="grid gap-6 sm:grid-cols-2"> + {cards.map((card) => ( + <div class="card-glow flex flex-col rounded-xl border border-border bg-[hsl(var(--surface))] p-8"> + <span class="mb-4 text-primary"> + {card.IconComponent && <card.IconComponent width={28} height={28} aria-hidden="true" />} + </span> + <h3 class="text-xl font-semibold text-foreground">{card.title}</h3> + <p class="mt-2 text-sm leading-relaxed text-muted-foreground flex-1">{card.desc}</p> + <a href={card.href} target="_blank" rel="noopener noreferrer" aria-describedby="new-tab-hint" class="docs-ext-link mt-5 text-sm font-medium"> + {card.cta} {ExternalLinkIcon && <ExternalLinkIcon width={12} height={12} aria-hidden="true" />} + </a> + </div> + ))} + </div> + </div> + {hasAside && ( + <div class="hidden lg:block"> + <div class="sticky top-24"> + <slot name="aside" /> + </div> + </div> + )} + </div> + </div> +</section> diff --git a/src/components/CommunitySection.tsx b/src/components/CommunitySection.tsx deleted file mode 100644 index 8104d15a3..000000000 --- a/src/components/CommunitySection.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { type ReactNode, type JSX } from "react"; -import { ExternalLink, Megaphone, CircleHelp, UserPlus, CalendarDays } from "lucide-react"; -import { COMMUNITY_URL } from "@/data/constants"; -import { SectionLabel } from "@/components/SectionLabel"; -import { SidebarLayout } from "@/components/SidebarLayout"; - -type Card = { - icon: ReactNode; - title: string; - desc: string; - cta: string; - href: string; -}; - -const cards: Card[] = [ - { - icon: <Megaphone size={28} aria-hidden="true" />, - title: "Community Voices", - desc: "Share tutorials, showcase projects, post open source news, and write about what you have learned. The home for community-created content.", - cta: "Share Something", - href: `${COMMUNITY_URL}/c/community-voices/38`, - }, - { - icon: <CircleHelp size={28} aria-hidden="true" />, - title: "Q&A", - desc: "Stuck on a technical problem or not sure where to start? Post a question and get answers from the community. No question is too basic.", - cta: "Ask a Question", - href: `${COMMUNITY_URL}/c/general/q-a/10`, - }, - { - icon: <UserPlus size={28} aria-hidden="true" />, - title: "Introduce Yourself", - desc: "New here? Tell us about yourself. Share your role, what you're building or learning, and one thing you want to get out of the community.", - cta: "Say Hello", - href: `${COMMUNITY_URL}/c/general/introductions/18`, - }, - { - icon: <CalendarDays size={28} aria-hidden="true" />, - title: "Events & Meetups", - desc: "Find upcoming events, add local meetups to the community calendar, and connect with members in your city.", - cta: "See Upcoming Events", - href: `${COMMUNITY_URL}/c/events-and-talks/12`, - }, -]; - -export const CommunitySection = ({ aside }: { aside?: ReactNode }): JSX.Element => { - const content = ( - <div> - <SectionLabel>community</SectionLabel> - <h2 id="community-section-heading" className="mb-6 text-3xl font-bold text-primary md:text-4xl"> - Get Involved - </h2> - <p className="mb-12 max-w-xl text-dim leading-relaxed"> - The community is where open source comes alive. Share what you know, ask for help, meet the people behind the projects, and find events near you. - </p> - <div className="grid gap-6 sm:grid-cols-2"> - {cards.map((card) => ( - <div - key={card.title} - className="card-glow flex flex-col rounded-xl border border-border bg-[hsl(var(--surface))] p-8" - > - <span className="mb-4 text-primary">{card.icon}</span> - <h3 className="text-xl font-semibold text-foreground">{card.title}</h3> - <p className="mt-2 text-sm leading-relaxed text-muted-foreground flex-1">{card.desc}</p> - <a - href={card.href} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - className="docs-ext-link mt-5 text-sm font-medium" - > - {card.cta} <ExternalLink size={12} aria-hidden="true" /> - </a> - </div> - ))} - </div> - </div> - ); - - return ( - <section aria-labelledby="community-section-heading" className="py-24 px-6 md:px-16"> - <div className="mx-auto max-w-6xl"> - <SidebarLayout aside={aside}>{content}</SidebarLayout> - </div> - </section> - ); -}; diff --git a/src/components/CommunitySidebar.astro b/src/components/CommunitySidebar.astro new file mode 100644 index 000000000..4fd626882 --- /dev/null +++ b/src/components/CommunitySidebar.astro @@ -0,0 +1,168 @@ +--- +// Fully static: discussion and leaderboard data are read at build time (no +// client fetch). Post ages are omitted; displaying them requires a client mount. +import IconMessageCircle from "~icons/lucide/message-circle"; +import IconExternalLink from "~icons/lucide/external-link"; +import ContributorBadge from "@/components/ContributorBadge.astro"; +import { COMMUNITY_URL } from "@/lib/site"; +import { stripHtml } from "@/lib/markdown"; +import type { Discussion, DiscussionPost, LeaderboardRow } from "@/lib/community-data"; + +const LEADERBOARD_ROWS_VISIBLE = 3; +const POSTS_VISIBLE = 3; + +// Inline avatar palette: bg color at 0.25 opacity, foreground text (Astro style= takes a string). +const AVATAR_COLORS = ["--primary", "--difficulty-architect", "--teal", "--difficulty-builder", "--destructive"]; +const avatarPalette = AVATAR_COLORS.map( + (c) => `background-color:hsl(var(${c}) / 0.25);color:hsl(var(--foreground))`, +); + +const isCertificatePost = (post: DiscussionPost): boolean => post.challengeSolved === true; +const displaySnippet = (post: DiscussionPost): string => { + if (!isCertificatePost(post)) return post.cooked; + const certRe = + /(?:---|—)\s*CERTIFICATE START\s*(?:---|—)[\s\S]*?(?:---|—)\s*CERTIFICATE END\s*(?:---|—)/; + const stripped = post.cooked.replace(certRe, "").trim(); + return stripped || "Completed the challenge."; +}; + +interface Props { + levelId: string; + discussionUrl: string; + contributor?: { name: string; url?: string }; + discussion: Discussion | null; + leaderboardRows: LeaderboardRow[]; +} +const { levelId, discussionUrl, contributor, discussion, leaderboardRows } = Astro.props; + +const hasThread = !!discussionUrl && discussionUrl !== COMMUNITY_URL; +const solvers: NonNullable<Discussion["solvers"]> = discussion?.solvers ?? []; +const hasLeaderboard = solvers.length > 0; +const topSolvers = solvers.slice(0, LEADERBOARD_ROWS_VISIBLE); + +// Points for this specific level, keyed by username. +const LEVEL_POINT_KEY: Record<string, keyof LeaderboardRow> = { + beginner: "beginnerPoints", + intermediate: "intermediatePoints", + expert: "expertPoints", + single: "singlePoints", +}; +const levelKey = LEVEL_POINT_KEY[levelId] ?? null; +const pointsByUsername = Object.fromEntries( + leaderboardRows.map((r: LeaderboardRow) => [r.username, levelKey !== null ? (r[levelKey] as number | undefined) : undefined]), +); + +const posts: DiscussionPost[] = discussion?.discussionPosts ?? []; +const nonCertPosts = posts.filter((p) => !isCertificatePost(p)); +// Show non-cert posts if available; fall back to cert posts so activity is never empty when posts exist +const visible = nonCertPosts.length > 0 ? nonCertPosts.slice(0, POSTS_VISIBLE) : posts.slice(0, POSTS_VISIBLE); +const hasActivity = visible.length > 0; +--- + +<div class="rounded-xl border border-border bg-[hsl(var(--surface))] p-5"> + <h2 class="font-sans text-base font-semibold text-foreground mb-5">Community</h2> + + {/* Challenge builder */} + { + contributor && ( + <div class="mb-5 pb-5 border-b border-border"> + <ContributorBadge name={contributor.name} url={contributor.url} /> + </div> + ) + } + + {/* Leaderboard */} + { + hasLeaderboard && ( + <div class="mb-5 pb-5 border-b border-border"> + <h3 class="font-mono text-xs uppercase tracking-widest text-faint mb-3">Leaderboard</h3> + <ol class="space-y-2.5" aria-label="Players who completed this challenge"> + {topSolvers.map((solver, i) => { + const points = pointsByUsername[solver.username]; + return ( + <li class="flex items-center gap-3 text-sm"> + <span class="font-mono text-xs text-faint w-4 shrink-0 text-right" aria-hidden="true"> + {i + 1} + </span> + {/* Discourse avatars are external and go stale when a user changes + theirs, so the initials chip is the fallback as well as the + no-avatar case: it renders hidden alongside the image and the + onerror swaps them. Without it a failed load shows a broken + image icon. Same pattern as AvatarLink.astro. */} + {solver.avatarUrl && ( + <img + src={solver.avatarUrl} + alt="" + aria-hidden="true" + width={24} + height={24} + loading="lazy" + decoding="async" + class="h-6 w-6 rounded-full shrink-0 object-cover" + onerror={`this.style.display='none';this.nextElementSibling.style.display='flex';`} + /> + )} + <span + class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-foreground" + style={`${avatarPalette[i % avatarPalette.length]}${solver.avatarUrl ? ";display:none" : ""}`} + aria-hidden="true" + > + {solver.username.slice(0, 2).toUpperCase()} + </span> + <span class="inline-flex items-center gap-1 font-medium text-foreground min-w-0 flex-1"> + <span class="truncate">{solver.username}</span> + </span> + {points != null && ( + <span class="shrink-0 font-mono text-xs font-semibold text-primary tabular-nums">{points} pts</span> + )} + </li> + ); + })} + </ol> + <p class="text-xs text-dim mt-3"> + Challenge solved by {solvers.length} {solvers.length === 1 ? "person" : "people"} + </p> + </div> + ) + } + + {/* Latest activity */} + { + hasActivity ? ( + <div class="mb-5"> + <h3 class="font-mono text-xs uppercase tracking-widest text-faint mb-3">Latest activity</h3> + <div class="space-y-3"> + {visible.map((post) => ( + <div class="text-xs"> + <p> + <span class="font-semibold text-foreground">{post.username}</span> + </p> + <p class="text-dim line-clamp-2 leading-snug mt-0.5">{stripHtml(displaySnippet(post))}</p> + </div> + ))} + </div> + </div> + ) : ( + <p class="text-sm text-dim leading-relaxed mb-5"> + {hasThread + ? "No posts yet. Be the first to share your solution or ask a question." + : "Got stuck or want to share your solution? Join the conversation."} + </p> + ) + } + + <div class="border-t border-border pt-4"> + <a + href={hasThread ? discussionUrl : COMMUNITY_URL} + target="_blank" + rel="noopener noreferrer" + aria-describedby="new-tab-hint" + class="btn-soft w-full" + > + <IconMessageCircle width={14} height={14} aria-hidden="true" /> + {hasThread ? "Share & Discuss" : "Join the Community"} + <IconExternalLink width={14} height={14} aria-hidden="true" /> + </a> + <p class="mt-2.5 text-xs text-faint font-mono text-center">Get help or share your solution</p> + </div> +</div> diff --git a/src/components/CommunitySidebar.tsx b/src/components/CommunitySidebar.tsx deleted file mode 100644 index c18a14135..000000000 --- a/src/components/CommunitySidebar.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { type JSX, useMemo } from "react"; -import { ExternalLink, MessageCircle } from "lucide-react"; -import { COMMUNITY_URL } from "@/data/constants"; -import { useDiscussionPosts } from "@/hooks/useDiscussionPosts"; -import { useAdventureLeaderboard, type LeaderboardRow } from "@/hooks/useAdventureLeaderboard"; -import { isCertificatePost, displaySnippet } from "@/lib/discussion-utils"; -import { ContributorBadge } from "@/components/ContributorBadge"; -import { LeaderboardList } from "@/components/LeaderboardList"; -import type { Adventure } from "@/data/adventures"; -import { makeAvatarPalette } from "@/lib/avatar-utils"; - -const LEADERBOARD_ROWS_VISIBLE = 3; -const POSTS_VISIBLE = 3; - -const avatarPalette = makeAvatarPalette(0.25); - -type CommunitySidebarProps = { - adventureId: string; - levelId: string; - discussionUrl: string; - contributor?: Adventure["contributor"]; -}; - -const SidebarLabel = ({ children }: { children: string }): JSX.Element => ( - <h3 className="font-mono text-xs uppercase tracking-widest text-faint mb-3"> - {children} - </h3> -); - -export const CommunitySidebar = ({ - adventureId, - levelId, - discussionUrl, - contributor, -}: CommunitySidebarProps): JSX.Element => { - const { posts, solvers, loaded } = useDiscussionPosts(adventureId, levelId); - const { rows: leaderboardRows } = useAdventureLeaderboard(adventureId); - const hasThread = discussionUrl !== COMMUNITY_URL; - - const hasLeaderboard = solvers.length > 0; - const topSolvers = solvers.slice(0, LEADERBOARD_ROWS_VISIBLE); - - // Points for this specific level, keyed by username. - const pointsByUsername = useMemo(() => { - const LEVEL_POINT_KEY = { - beginner: "beginnerPoints", - intermediate: "intermediatePoints", - expert: "expertPoints", - single: "singlePoints", - } as const satisfies Partial<Record<string, keyof LeaderboardRow>>; - const levelKey = LEVEL_POINT_KEY[levelId as keyof typeof LEVEL_POINT_KEY] ?? null; - return Object.fromEntries( - leaderboardRows.map((r) => [ - r.username, - levelKey !== null ? r[levelKey] : undefined, - ]) - ); - }, [leaderboardRows, levelId]); - - const nonCertPosts = useMemo(() => posts.filter((p) => !isCertificatePost(p)), [posts]); - // Show non-cert posts if available; fall back to cert posts so activity is never empty when posts exist - const visible = nonCertPosts.length > 0 - ? nonCertPosts.slice(0, POSTS_VISIBLE) - : posts.slice(0, POSTS_VISIBLE); - const hasActivity = visible.length > 0; - - return ( - <div className="rounded-xl border border-border bg-[hsl(var(--surface))] p-5"> - <h2 className="font-sans text-base font-semibold text-foreground mb-5"> - Community - </h2> - - {/* Challenge builder */} - {contributor && ( - <div className="mb-5 pb-5 border-b border-border"> - <ContributorBadge name={contributor.name} url={contributor.url} /> - </div> - )} - - {/* Leaderboard */} - {hasLeaderboard && ( - <div className="mb-5 pb-5 border-b border-border"> - <SidebarLabel>Leaderboard</SidebarLabel> - <LeaderboardList - label="Players who completed this challenge" - rows={topSolvers.map((solver, i) => ({ - rank: i + 1, - username: solver.username, - avatarUrl: solver.avatarUrl, - points: pointsByUsername[solver.username], - avatarFallbackStyle: avatarPalette[i % avatarPalette.length], - }))} - /> - <p className="text-xs text-dim mt-3"> - Challenge solved by {solvers.length} {solvers.length === 1 ? "person" : "people"} - </p> - </div> - )} - - {/* Latest activity */} - {hasActivity ? ( - <div className="mb-5"> - <SidebarLabel>Latest activity</SidebarLabel> - - <div className="space-y-3"> - {visible.map((post) => ( - <div key={`${post.username}-${post.created_at}`} className="text-xs"> - <p> - <span className="font-semibold text-foreground">{post.username}</span> - {post.age && ( - <span className="text-faint"> · {post.age}</span> - )} - </p> - <p className="text-dim line-clamp-2 leading-snug mt-0.5"> - {displaySnippet(post)} - </p> - </div> - ))} - </div> - - </div> - ) : loaded ? ( - <p className="text-sm text-dim leading-relaxed mb-5"> - {hasThread - ? "No posts yet. Be the first to share your solution or ask a question." - : "Got stuck or want to share your solution? Join the conversation."} - </p> - ) : null} - - <div className="border-t border-border pt-4"> - <a - href={hasThread ? discussionUrl : COMMUNITY_URL} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - className="btn-soft w-full" - > - <MessageCircle size={14} aria-hidden="true" /> - {hasThread ? "Share & Discuss" : "Join the Community"} - <ExternalLink size={14} aria-hidden="true" /> - - </a> - <p className="mt-2.5 text-xs text-faint font-mono text-center"> - Get help or share your solution - </p> - </div> - </div> - ); -}; diff --git a/src/components/ConsentBanner.astro b/src/components/ConsentBanner.astro new file mode 100644 index 000000000..574685a3a --- /dev/null +++ b/src/components/ConsentBanner.astro @@ -0,0 +1,147 @@ +--- +import IconCookie from "~icons/lucide/cookie"; + +// Analytics consent UI. Static markup plus one script; no island. +// +// Both states are rendered and both start `hidden`; the script reveals whichever +// matches the stored choice. The state machine itself is untouched and still +// lives in src/stores/consent.ts, which owns the storage format, the 180-day +// expiry, GPC and the gtag injection order. +// +// Analytics lifecycle (page_view, click tracking) is deliberately NOT wired up +// here. It lives in its own script in Layout.astro so it does not depend on this +// component mounting. + +const privacyUrl = `${import.meta.env.BASE_URL}privacy/`; +--- + +{/* The live region wraps both states and is always present, so assistive tech + has it registered before either appears. aria-atomic so the whole region is + re-read on a transition, not just the changed subtree. */} +<div aria-live="polite" aria-atomic="true"> + <div + data-consent-banner + hidden + role="region" + aria-labelledby="consent-banner-title" + class="fixed inset-x-0 bottom-0 z-50 border-t border-border bg-background/95 shadow-lg backdrop-blur" + > + {/* max-h-[80vh] + overflow-y-auto keep the actions reachable at 400% zoom + and on short landscape viewports (WCAG 1.4.10). The safe-area padding + keeps them clear of the iOS home indicator. */} + <div + class="mx-auto flex max-h-[80vh] max-w-7xl flex-col gap-4 overflow-y-auto px-4 py-5 sm:flex-row sm:items-start sm:gap-8 sm:px-6" + style="padding-bottom: calc(1.25rem + env(safe-area-inset-bottom, 0px))" + > + <div class="flex-1"> + <p id="consent-banner-title" class="text-sm font-semibold text-foreground"> + This site uses analytics cookies + </p> + <p class="mt-1 text-sm text-dim"> + We use Google Analytics to understand how visitors use offon.dev. No data is sent to + Google until you accept. You can change your preference at any time. See our{" "} + <a + href={privacyUrl} + class="text-dim underline underline-offset-2 hover:text-foreground focus-ring rounded-sm" + >Privacy Policy</a + >{" "} + for details. + </p> + </div> + {/* Decline comes first in DOM and tab order and uses .btn-secondary + (solid, same geometry as .btn-primary), so declining is no harder or + less prominent than accepting. */} + <div class="flex shrink-0 gap-2"> + <button + type="button" + data-consent-decline + class="btn-secondary" + aria-label="Decline analytics cookies" + > + Decline + </button> + <button + type="button" + data-consent-accept + class="btn-primary" + aria-label="Accept analytics cookies" + > + Accept Analytics + </button> + </div> + </div> + </div> + + <button + type="button" + data-consent-reset + hidden + class="focus-ring fixed right-4 z-40 flex h-11 w-11 items-center justify-center rounded-full border border-border bg-background text-dim shadow-sm hover:text-foreground" + style="bottom: calc(env(safe-area-inset-bottom, 0px) + 1.25rem)" + aria-label="Change cookie preferences" + > + <IconCookie width={18} height={18} aria-hidden="true" /> + </button> +</div> + +<script> + import { $consent, grant, deny, reset, initConsent } from "@/stores/consent"; + + // Module-scope teardown; null when no page is mounted. + let teardown: (() => void) | null = null; + + function initConsentBanner(): void { + const banner = document.querySelector<HTMLElement>("[data-consent-banner]"); + const resetBtn = document.querySelector<HTMLButtonElement>("[data-consent-reset]"); + const declineBtn = document.querySelector<HTMLButtonElement>("[data-consent-decline]"); + const acceptBtn = document.querySelector<HTMLButtonElement>("[data-consent-accept]"); + + if (!banner || !resetBtn || !declineBtn || !acceptBtn) return; + + // Undecided shows the banner; decided shows the floating preferences button. + // Re-run the subscription immediately so the Reset button reflects the + // current state after navigation (it starts `hidden` in the server HTML). + const unsubscribe = $consent.subscribe((value) => { + banner.hidden = value !== null; + resetBtn.hidden = value === null; + }); + + // Focus moves only from these handlers, never from the subscription above. + // initConsent() restoring a stored choice is a state change but not a user + // action, and focusing there would yank focus off the skip-nav link on every + // page load for every returning visitor. + const onAccept = () => { + grant(); + resetBtn.focus(); + }; + const onDecline = () => { + deny(); + resetBtn.focus(); + }; + const onReset = () => { + reset(); + declineBtn.focus(); + }; + + acceptBtn.addEventListener("click", onAccept); + declineBtn.addEventListener("click", onDecline); + resetBtn.addEventListener("click", onReset); + + teardown = () => { + unsubscribe(); + acceptBtn.removeEventListener("click", onAccept); + declineBtn.removeEventListener("click", onDecline); + resetBtn.removeEventListener("click", onReset); + }; + + // GPC check plus restore of any stored choice. Runs after the subscription + // so the resulting state is rendered. + initConsent(); + } + + document.addEventListener("astro:page-load", initConsentBanner); + document.addEventListener("astro:before-swap", () => { + teardown?.(); + teardown = null; + }); +</script> diff --git a/src/components/ConsentBanner.tsx b/src/components/ConsentBanner.tsx deleted file mode 100644 index d9a6b70c7..000000000 --- a/src/components/ConsentBanner.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useState, useEffect, useRef, type JSX } from "react"; -import { Link } from "react-router"; -import { Cookie } from "lucide-react"; -import { useConsent } from "@/hooks/useConsent"; -import { SITE_NAME } from "@/data/constants"; - -export function ConsentBanner(): JSX.Element { - const { consent, grant, deny, reset } = useConsent(); - const [mounted, setMounted] = useState(false); - const declineRef = useRef<HTMLButtonElement>(null); - const prevConsentRef = useRef<string | null | undefined>(undefined); - - useEffect(() => { - setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect -- mount guard; SSG requires a safe default (null) on first render so the banner is absent from prerendered HTML and cannot become the LCP element - }, []); - - // Move focus to Decline only when the banner reappears after a reset (consent - // transitions from non-null to null). Skips the initial page-load case so the - // banner never steals focus from the skip nav link. - useEffect(() => { - const prevConsent = prevConsentRef.current; - prevConsentRef.current = consent; - if (mounted && consent === null && prevConsent != null) { - declineRef.current?.focus(); - } - }, [mounted, consent]); - - // The outer live region persists across all render paths so AT establishes it - // before any content appears. Content changes inside the same DOM node, ensuring - // AT announces transitions including the reset path (cookie button back to banner). - return ( - <div aria-live="polite" aria-atomic="true"> - {!mounted ? null : consent !== null ? ( - <button - type="button" - onClick={reset} - aria-label="Cookie Preferences" - style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5rem)' }} - className="fixed right-4 z-50 flex h-11 w-11 items-center justify-center rounded-full border border-border bg-background text-muted-foreground shadow-md transition-colors hover:bg-accent hover:text-accent-foreground focus-ring" - > - <Cookie size={18} aria-hidden="true" /> - </button> - ) : ( - <div - role="region" - aria-labelledby="consent-banner-title" - style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} - className="fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-background shadow-lg" - > - <div className="mx-auto flex max-h-[80vh] max-w-screen-xl flex-col gap-4 overflow-y-auto px-4 py-5 sm:flex-row sm:items-start sm:gap-8 sm:px-6"> - <div className="flex-1 space-y-1"> - <p id="consent-banner-title" className="text-sm text-foreground">This site uses analytics cookies</p> - <p className="text-sm text-muted-foreground"> - We use Google Analytics to understand how visitors use {SITE_NAME}. No data is sent to - Google until you accept. You can change your preference at any time. See our{" "} - <Link - to="/privacy/" - className="underline underline-offset-2 hover:text-foreground focus-ring-tight rounded-sm" - > - Privacy Policy - </Link>{" "} - for details. - </p> - </div> - <div className="flex shrink-0 flex-wrap gap-2 sm:items-center"> - <button - ref={declineRef} - type="button" - onClick={deny} - aria-label="Decline analytics cookies" - className="btn-ghost" - > - Decline - </button> - <button - type="button" - onClick={grant} - aria-label="Accept analytics cookies" - className="btn-primary" - > - Accept Analytics - </button> - </div> - </div> - </div> - )} - </div> - ); -} diff --git a/src/components/ContributorBadge.astro b/src/components/ContributorBadge.astro new file mode 100644 index 000000000..091db9c5d --- /dev/null +++ b/src/components/ContributorBadge.astro @@ -0,0 +1,41 @@ +--- +import IconHammer from "~icons/lucide/hammer"; +import IconExternalLink from "~icons/lucide/external-link"; + +interface Props { + name: string; + url?: string; + glow?: boolean; + label?: string; +} +const { name, url, glow = false, label = "Challenge Builder" } = Astro.props; +const pill = [ + "contributor-pill inline-flex items-center gap-1.5 rounded-full border border-primary/20 bg-primary/5 px-2.5 py-1 text-xs text-primary", + glow && "contributor-pill-glow", +]; +--- + +{ + url ? ( + <a + href={url} + target="_blank" + rel="noopener noreferrer" + aria-describedby="new-tab-hint" + class:list={[...pill, "hover:border-primary/40 hover:bg-primary/10 transition-colors focus-ring-tight"]} + > + <IconHammer width={11} height={11} aria-hidden="true" /> + <span>{label}</span> + <span aria-hidden="true" class="inline-block h-3 w-px bg-current opacity-40" /> + <span>{name}</span> + <IconExternalLink width={11} height={11} aria-hidden="true" /> + </a> + ) : ( + <span class:list={pill}> + <IconHammer width={11} height={11} aria-hidden="true" /> + <span>{label}</span> + <span aria-hidden="true" class="inline-block h-3 w-px bg-current opacity-40" /> + <span>{name}</span> + </span> + ) +} diff --git a/src/components/ContributorBadge.tsx b/src/components/ContributorBadge.tsx deleted file mode 100644 index 2c6674a73..000000000 --- a/src/components/ContributorBadge.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { JSX } from "react"; -import { Hammer, ExternalLink } from "lucide-react"; - -type ContributorBadgeProps = { - name: string; - url?: string; - glow?: boolean; - label?: string; -}; - -const basePillClass = - "contributor-pill inline-flex items-center gap-1.5 rounded-full border border-primary/20 bg-primary/5 px-2.5 py-1 text-xs text-primary"; - -export const ContributorBadge = ({ name, url, glow = false, label = "Challenge Builder" }: ContributorBadgeProps): JSX.Element => { - const pillClass = glow ? `${basePillClass} contributor-pill-glow` : basePillClass; - - const content = ( - <> - <Hammer size={11} aria-hidden="true" /> - <span>{label}</span> - <span aria-hidden="true" className="inline-block w-px h-3 bg-current opacity-40" /> - <span>{name}</span> - </> - ); - - if (url) { - return ( - <a - href={url} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - className={`${pillClass} hover:border-primary/40 hover:bg-primary/10 transition-colors focus-ring-tight`} - > - {content} - <ExternalLink size={11} aria-hidden="true" /> - - </a> - ); - } - - return <span className={pillClass}>{content}</span>; -}; diff --git a/src/components/DifficultyBadge.astro b/src/components/DifficultyBadge.astro new file mode 100644 index 000000000..aacd06764 --- /dev/null +++ b/src/components/DifficultyBadge.astro @@ -0,0 +1,18 @@ +--- +import { difficultyStyle, type Difficulty } from "@/lib/difficulty"; + +interface Props { + difficulty: Difficulty; + showDot?: boolean; +} +const { difficulty, showDot = false } = Astro.props; +--- + +<span + class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 font-mono text-xs font-semibold uppercase tracking-wider transition-colors" + style={difficultyStyle(difficulty)} + data-difficulty={difficulty} +> + {showDot && <span class="h-2 w-2 rounded-full bg-current" aria-hidden="true"></span>} + {difficulty} +</span> diff --git a/src/components/DifficultyBadge.tsx b/src/components/DifficultyBadge.tsx deleted file mode 100644 index a57b0a0b0..000000000 --- a/src/components/DifficultyBadge.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { JSX } from "react"; -import { Badge } from "@/components/ui/badge"; -import type { AdventureLevel } from "@/data/adventures"; -import { difficultyStyle } from "@/lib/difficulty"; - -type Difficulty = AdventureLevel["difficulty"]; - -type DifficultyBadgeProps = { - difficulty: Difficulty; - showDot?: boolean; -} - -export const DifficultyBadge = ({ difficulty, showDot = false }: DifficultyBadgeProps): JSX.Element => ( - <Badge - variant="outline" - className="gap-1.5 rounded-md py-1 font-mono text-xs uppercase tracking-wider" - style={difficultyStyle(difficulty)} - data-difficulty={difficulty} - > - {showDot && ( - <span className="h-2 w-2 rounded-full bg-current" aria-hidden="true" /> - )} - {difficulty} - </Badge> -); diff --git a/src/components/DiscussionSection.tsx b/src/components/DiscussionSection.tsx deleted file mode 100644 index 11bc1d50f..000000000 --- a/src/components/DiscussionSection.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { useState, type JSX } from "react"; -import { ExternalLink, Heart, Trophy } from "lucide-react"; -import { COMMUNITY_URL, COMMUNITY_DISPLAY_NAME } from "@/data/constants"; -import { useDiscussionPosts } from "@/hooks/useDiscussionPosts"; -import { isCertificatePost, displaySnippet } from "@/lib/discussion-utils"; -import { makeAvatarPalette } from "@/lib/avatar-utils"; - -const avatarPalette = makeAvatarPalette(0.2); - -type DiscussionSectionProps = { - adventureId: string; - levelId: string; - discussionUrl: string; -}; - -export const DiscussionSection = ({ adventureId, levelId, discussionUrl }: DiscussionSectionProps): JSX.Element => { - const { posts: allPosts, loaded } = useDiscussionPosts(adventureId, levelId); - const posts = allPosts.slice(0, 3); - // Avatars are external (Discourse). Track per-post load failures so a broken - // image falls back to the initials chip instead of a broken-image icon. - const [failedAvatars, setFailedAvatars] = useState<Record<number, true>>({}); - - const joinLink = ( - <a - href={discussionUrl || COMMUNITY_URL} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - className="docs-ext-link mt-4 text-sm font-medium" - > - Join the Discussion on {COMMUNITY_DISPLAY_NAME} <ExternalLink size={12} aria-hidden="true" /> - </a> - ); - - const statusMessage = loaded - ? posts.length === 0 - ? "No discussion posts loaded." - : `${posts.length} recent discussion post${posts.length !== 1 ? "s" : ""} shown.` - : ""; - - return ( - <div className="space-y-4"> - <h2 className="text-lg font-semibold text-foreground mb-4">Discussion</h2> - <span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{statusMessage}</span> - <div> - {loaded && posts.length === 0 ? ( - <> - <div className="card-glow rounded-xl border border-border bg-[hsl(var(--surface))] p-8 text-center"> - <p className="text-muted-foreground text-sm"> - No community posts yet. Be the first to share! - </p> - </div> - {joinLink} - </> - ) : loaded ? ( - <> - {posts.map((post, i) => ( - <a - key={`${post.username}-${post.created_at}`} - href={post.topicUrl} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - className="block card-glow rounded-xl border border-border bg-[hsl(var(--surface))] p-5 transition-all focus-ring" - > - <div className="flex items-center justify-between mb-3"> - <div className="flex items-center gap-3"> - {post.avatarUrl && !failedAvatars[i] ? ( - <img - src={post.avatarUrl} - alt="" - aria-hidden="true" - width={32} - height={32} - loading="lazy" - decoding="async" - onError={() => setFailedAvatars((f) => ({ ...f, [i]: true }))} - className="h-8 w-8 shrink-0 rounded-full object-cover" - /> - ) : ( - <div - className="flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold" - style={avatarPalette[i % avatarPalette.length]} - aria-hidden="true" - > - {post.username.slice(0, 2).toUpperCase()} - </div> - )} - <span className="sr-only">{post.username || "Community member"}{post.age ? `, posted ${post.age}` : ""}: </span> - {post.age && ( - <span className="text-xs text-faint" aria-hidden="true">{post.age}</span> - )} - </div> - {(post.like_count ?? 0) > 0 && ( - <span className="inline-flex items-center gap-1 text-xs text-muted-foreground"> - <Heart size={12} aria-hidden="true" /> - <span className="sr-only">Likes: </span> - {post.like_count} - </span> - )} - </div> - <p className="text-sm leading-relaxed text-muted-foreground line-clamp-3"> - {isCertificatePost(post) && <Trophy size={14} className="inline mr-1 text-primary" aria-hidden="true" />} - {displaySnippet(post)} - </p> - - </a> - ))} - {joinLink} - </> - ) : null} - </div> - </div> - ); -}; diff --git a/src/components/FilteredLevelCard.tsx b/src/components/FilteredLevelCard.tsx deleted file mode 100644 index 394810d60..000000000 --- a/src/components/FilteredLevelCard.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import type { JSX } from "react"; -import { Link } from "react-router"; -import { Clock } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { stripLinks } from "@/lib/markdown"; -import type { AdventureLevelSummary } from "@/data/adventures"; -import { DifficultyBadge } from "@/components/DifficultyBadge"; -import { LivePill } from "@/components/LivePill"; -import { AdventureIcon } from "@/components/AdventureIcon"; - -type FilteredLevelCardProps = { - level: AdventureLevelSummary; - adventureId: string; - adventureTitle: string; - isLive?: boolean; - className?: string; - adventureIcon?: string; -}; - -export const FilteredLevelCard = ({ - level, - adventureId, - adventureTitle, - isLive, - className, - adventureIcon, -}: FilteredLevelCardProps): JSX.Element => ( - <Link - to={`/adventures/${adventureId}/levels/${level.id}/`} - aria-label={`${level.name}: ${level.difficulty}, ${adventureTitle}`} - className={cn( - "group card-glow rounded-xl border border-border bg-[hsl(var(--surface))] p-6 flex flex-col focus-ring", - className - )} - > - <div className="flex items-center justify-between mb-3"> - <DifficultyBadge difficulty={level.difficulty} showDot /> - {isLive && <LivePill />} - </div> - <h3 className="text-lg font-semibold text-foreground group-hover:text-primary transition-colors"> - {level.name}{adventureIcon && <span className="inline-flex items-center align-middle ml-1 text-muted-foreground"><AdventureIcon icon={adventureIcon} size={16} /></span>} - </h3> - <ul role="list" className="mt-3 space-y-1.5"> - {level.learnings.slice(0, 3).map((learning) => ( - <li key={learning} className="flex items-start gap-2 text-sm text-muted-foreground"> - <span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-primary" aria-hidden="true" /> - <span className="min-w-0 md-inline" dangerouslySetInnerHTML={{ __html: stripLinks(learning) }} /> - </li> - ))} - </ul> - <div className="mt-auto pt-4 flex flex-wrap gap-1.5 items-center justify-between"> - <div className="flex items-center gap-1.5"> - <span className="font-mono text-xs text-muted-foreground">Challenge</span> - {level.estimatedTime && ( - <span className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 font-mono text-xs text-faint"> - <Clock size={10} aria-hidden="true" /> - {level.estimatedTime} - </span> - )} - </div> - <span className="rounded-sm border border-border px-2 py-0.5 text-xs text-faint"> - {adventureTitle} - </span> - </div> - </Link> -); diff --git a/src/components/Footer.astro b/src/components/Footer.astro new file mode 100644 index 000000000..e4d6ca48d --- /dev/null +++ b/src/components/Footer.astro @@ -0,0 +1,105 @@ +--- +import IconExternalLink from "~icons/lucide/external-link"; +import IconZap from "~icons/lucide/zap"; +import { + BRAND_NAME, + BRAND_SHORT_DESCRIPTION, + BRAND_SLOGAN_PARTS, + COMMUNITY_URL, + CODE_OF_CONDUCT_URL, + CONTACT_EMAIL, + LINKEDIN_URL, + BLUESKY_URL, + X_URL, + CURRENT_YEAR, + SITE_NAME, +} from "@/lib/site"; + +const base = import.meta.env.BASE_URL; +const logoDark = `${base}brand/offon-logo-dark-color.svg`; +const logoLight = `${base}brand/offon-logo-light-mono.svg`; + +const explore = [ + { href: `${base}challenges/`, label: "Challenges" }, + { href: `${base}contribute/`, label: "Contribute" }, + { href: `${base}handbook/`, label: "Handbook" }, + { href: `${base}about/`, label: "About" }, + { href: `${base}brand/`, label: "Brand" }, +]; + +const linkCls = + "flex items-center gap-1 min-h-[48px] font-sans text-sm text-dim hover:text-foreground dark:hover:text-primary transition-colors underline underline-offset-4 decoration-[3px] decoration-transparent focus-ring-tight rounded-sm"; +const socialCls = + "flex items-center justify-center p-3 text-faint hover:text-foreground dark:hover:text-primary transition-colors focus-ring-tight rounded-sm"; +--- + +<footer class="bg-background border-t border-border px-6 sm:px-8 md:px-16 lg:px-20"> + <div class="mx-auto max-w-6xl py-16 grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-16"> + {/* Brand */} + <div> + <div class="mb-4"> + <img src={logoDark} alt={SITE_NAME} width={104} height={26} loading="lazy" decoding="async" class="h-5 hidden dark:block" /> + <img src={logoLight} alt={SITE_NAME} width={104} height={26} loading="lazy" decoding="async" class="h-5 block dark:hidden" /> + </div> + <p class="font-sans text-sm text-dim leading-relaxed md:max-w-xs">{BRAND_SHORT_DESCRIPTION}</p> + </div> + + {/* Nav columns */} + <div class="grid grid-cols-1 sm:grid-cols-2 gap-8 md:gap-12"> + <nav aria-label="Explore"> + <p class="font-sans font-normal text-xs uppercase tracking-widest text-faint mb-3">explore</p> + <div class="flex flex-col"> + {explore.map((l) => ( + <a href={l.href} class={linkCls}>{l.label}</a> + ))} + </div> + </nav> + <nav aria-label="Community"> + <p class="font-sans font-normal text-xs uppercase tracking-widest text-faint mb-3">community</p> + <div class="flex flex-col"> + <a href={COMMUNITY_URL} target="_blank" rel="noopener noreferrer" aria-describedby="new-tab-hint" class={linkCls}> + Community Hub <IconExternalLink width={12} height={12} aria-hidden="true" /> + </a> + <a href={CODE_OF_CONDUCT_URL} target="_blank" rel="noopener noreferrer" aria-describedby="new-tab-hint" class={linkCls}> + Code of Conduct <IconExternalLink width={12} height={12} aria-hidden="true" /> + </a> + <a href={`${base}privacy/`} class={linkCls}>Privacy Policy</a> + <a href={`${base}accessibility/`} class={linkCls}>Accessibility</a> + <a href={`mailto:${CONTACT_EMAIL}`} class={linkCls}>Contact</a> + </div> + </nav> + </div> + </div> + + {/* Bottom strip */} + <div class="border-t border-border py-4"> + <div class="mx-auto max-w-6xl flex flex-col items-center gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4"> + <span class="text-xs text-faint shrink-0 sm:order-1">© {CURRENT_YEAR} {BRAND_NAME}. All rights reserved.</span> + <span class="inline-flex items-center justify-center gap-1.5 text-xs text-faint sm:order-2 sm:flex-1"> + <IconZap width={10} height={10} aria-hidden="true" /> + <span>{BRAND_SLOGAN_PARTS[0]}</span> + <IconZap width={10} height={10} aria-hidden="true" /> + <span>{BRAND_SLOGAN_PARTS[1]}</span> + <IconZap width={10} height={10} aria-hidden="true" /> + <span>{BRAND_SLOGAN_PARTS[2]}</span> + </span> + <div class="flex items-center gap-3 shrink-0 sm:order-3"> + <a href={LINKEDIN_URL} target="_blank" rel="noopener noreferrer" aria-describedby="new-tab-hint" aria-label="LinkedIn" class={socialCls}> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false" class="w-3.5 h-3.5" fill="currentColor"> + <path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" /> + </svg> + </a> + <a href={BLUESKY_URL} target="_blank" rel="noopener noreferrer" aria-describedby="new-tab-hint" aria-label="Bluesky" class={socialCls}> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false" class="w-3.5 h-3.5" fill="currentColor"> + <path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.479 0-.689-.139-1.861-.902-2.203-.659-.299-1.664-.621-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8z" /> + </svg> + </a> + <a href={X_URL} target="_blank" rel="noopener noreferrer" aria-describedby="new-tab-hint" aria-label="X / Twitter" class={socialCls}> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false" class="w-3.5 h-3.5" fill="currentColor"> + <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /> + </svg> + </a> + </div> + </div> + </div> +</footer> diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx deleted file mode 100644 index 0b9da9606..000000000 --- a/src/components/Footer.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import type { JSX } from "react"; -import { NavLink } from "@/components/NavLink"; -import { Zap, ExternalLink } from "lucide-react"; -import { useTheme } from "@/hooks/useTheme"; -import { BRAND_NAME, BRAND_SHORT_DESCRIPTION, BRAND_SLOGAN_PARTS, CODE_OF_CONDUCT_URL, COMMUNITY_URL, CONTACT_EMAIL, CURRENT_YEAR, LINKEDIN_URL, BLUESKY_URL, X_URL, SITE_NAME } from "@/data/constants"; -const logoDark = `${import.meta.env.BASE_URL}brand/offon-logo-dark-color.svg`; -const logoLight = `${import.meta.env.BASE_URL}brand/offon-logo-light-mono.svg`; - -const linkCls = "flex items-center gap-1 min-h-[48px] font-sans text-sm text-dim hover:text-foreground dark:hover:text-primary transition-colors underline underline-offset-4 decoration-[3px] decoration-transparent focus-ring-tight rounded-sm"; - -export const Footer = (): JSX.Element => { - const { theme } = useTheme(); - - return ( - <footer className="bg-background border-t border-border px-6 sm:px-8 md:px-16 lg:px-20"> - <div className="mx-auto max-w-6xl py-16 grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-16"> - - {/* Brand */} - <div> - <div className="mb-4"> - <img src={theme === "dark" ? logoDark : logoLight} alt={SITE_NAME} width={104} height={26} loading="lazy" decoding="async" className="h-5" /> - </div> - <p className="font-sans text-sm text-dim leading-relaxed md:max-w-xs"> - {BRAND_SHORT_DESCRIPTION} - </p> - </div> - - {/* Nav columns */} - <div className="grid grid-cols-1 sm:grid-cols-2 gap-8 md:gap-12"> - {/* Explore */} - <nav aria-label="Explore"> - <p className="font-sans font-normal text-xs uppercase tracking-widest text-faint mb-3">explore</p> - <div className="flex flex-col"> - <NavLink to="/challenges/" className={linkCls}>Challenges</NavLink> - <NavLink to="/contribute/" className={linkCls}>Contribute</NavLink> - <NavLink to="/handbook/" className={linkCls}>Handbook</NavLink> - <NavLink to="/about/" className={linkCls}>About</NavLink> - <NavLink to="/brand/" className={linkCls}>Brand</NavLink> - </div> - </nav> - {/* Community */} - <nav aria-label="Community"> - <p className="font-sans font-normal text-xs uppercase tracking-widest text-faint mb-3">community</p> - <div className="flex flex-col"> - <a href={COMMUNITY_URL} target="_blank" rel="noopener noreferrer" aria-describedby="new-tab-hint" className={linkCls}>Community Hub <ExternalLink size={12} aria-hidden="true" /></a> - <a href={CODE_OF_CONDUCT_URL} target="_blank" rel="noopener noreferrer" aria-describedby="new-tab-hint" className={linkCls}>Code of Conduct <ExternalLink size={12} aria-hidden="true" /></a> - <NavLink to="/privacy/" className={linkCls}>Privacy Policy</NavLink> - <NavLink to="/accessibility/" className={linkCls}>Accessibility</NavLink> - <a href={`mailto:${CONTACT_EMAIL}`} className={linkCls}>Contact</a> - </div> - </nav> - </div> - </div> - - {/* Bottom strip */} - <div className="border-t border-border py-4"> - <div className="mx-auto max-w-6xl flex flex-col items-center gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4"> - <span className="text-xs text-faint shrink-0 sm:order-1">© {CURRENT_YEAR} {BRAND_NAME}. All rights reserved.</span> - <span className="inline-flex items-center justify-center gap-1.5 text-xs text-faint sm:order-2 sm:flex-1"> - <Zap size={10} aria-hidden="true" /> - <span>{BRAND_SLOGAN_PARTS[0]}</span> - <Zap size={10} aria-hidden="true" /> - <span>{BRAND_SLOGAN_PARTS[1]}</span> - <Zap size={10} aria-hidden="true" /> - <span>{BRAND_SLOGAN_PARTS[2]}</span> - </span> - <div className="flex items-center gap-3 shrink-0 sm:order-3"> - <a - href={LINKEDIN_URL} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - aria-label="LinkedIn" - className="flex items-center justify-center p-3 text-faint hover:text-foreground dark:hover:text-primary transition-colors focus-ring-tight rounded-sm" - > - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false" className="w-3.5 h-3.5" fill="currentColor"> - <path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" /> - </svg> - </a> - <a - href={BLUESKY_URL} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - aria-label="Bluesky" - className="flex items-center justify-center p-3 text-faint hover:text-foreground dark:hover:text-primary transition-colors focus-ring-tight rounded-sm" - > - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false" className="w-3.5 h-3.5" fill="currentColor"> - <path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.479 0-.689-.139-1.861-.902-2.203-.659-.299-1.664-.621-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8z" /> - </svg> - </a> - <a - href={X_URL} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - aria-label="X / Twitter" - className="flex items-center justify-center p-3 text-faint hover:text-foreground dark:hover:text-primary transition-colors focus-ring-tight rounded-sm" - > - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false" className="w-3.5 h-3.5" fill="currentColor"> - <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /> - </svg> - </a> - </div> - </div> - </div> - </footer> - ); -}; diff --git a/src/components/Hero.astro b/src/components/Hero.astro new file mode 100644 index 000000000..96e47512e --- /dev/null +++ b/src/components/Hero.astro @@ -0,0 +1,65 @@ +--- +import IconZap from "~icons/lucide/zap"; +import IconArrowDown from "~icons/lucide/arrow-down"; +import IconExternalLink from "~icons/lucide/external-link"; +import { BRAND_SLOGAN_PARTS, BRAND_SECONDARY_LINE_PARTS, COMMUNITY_URL } from "@/lib/site"; + +const FIREFLY_COUNT = 8; +const fireflies = Array.from({ length: FIREFLY_COUNT }); +--- + +<section + aria-labelledby="hero-heading" + class="relative flex min-h-dvh items-center justify-center px-6 pt-20" +> + <div class="pointer-events-none absolute inset-0" aria-hidden="true"> + {fireflies.map(() => <span class="firefly"></span>)} + </div> + + <div class="relative z-10 mx-auto max-w-3xl text-center"> + <div + class="animate-fade-up hero-badge mb-8 inline-flex items-center gap-2 rounded-full border border-primary/20 bg-[hsl(var(--surface))] px-4 py-1.5" + > + <IconZap width={12} height={12} aria-hidden="true" class="text-foreground" /> + <span class="text-sm text-foreground">{BRAND_SLOGAN_PARTS[0]}</span> + <IconZap width={14} height={14} aria-hidden="true" class="text-foreground" /> + <span class="text-sm text-foreground">{BRAND_SLOGAN_PARTS[1]}</span> + <IconZap width={14} height={14} aria-hidden="true" class="text-foreground" /> + <span class="text-sm text-foreground">{BRAND_SLOGAN_PARTS[2]}</span> + </div> + + <h1 + id="hero-heading" + class="animate-fade-up-delay-1 font-heading text-4xl font-bold leading-tight tracking-tight sm:text-5xl md:text-6xl lg:text-7xl" + > + <span class="block text-primary">{BRAND_SECONDARY_LINE_PARTS[0]}</span> + <span class="block text-foreground">{BRAND_SECONDARY_LINE_PARTS[1]}</span> + </h1> + + <p class="animate-fade-up-delay-2 mx-auto mt-6 max-w-xl font-sans text-lg leading-relaxed text-dim"> + A welcoming community for open source enthusiasts. Learn through hands-on challenges, share your + knowledge, and grow alongside people who love open source as much as you do. + </p> + + <div class="animate-fade-up-delay-3 mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row"> + {/* In-page jump to the adventure grid further down this page, which is why + the icon is a down arrow. Hero is only rendered on the home page, so + the #challenges target always exists. html { scroll-padding-top } + clears the fixed navbar. */} + <a href="#challenges" class="btn-primary inline-flex items-center gap-1"> + Start a Challenge + <IconArrowDown width={16} height={16} aria-hidden="true" /> + </a> + <a + href={COMMUNITY_URL} + target="_blank" + rel="noopener noreferrer" + aria-describedby="new-tab-hint" + class="btn-ghost inline-flex items-center gap-1" + > + Join the Community + <IconExternalLink width={16} height={16} aria-hidden="true" /> + </a> + </div> + </div> +</section> diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx deleted file mode 100644 index 0f133b4f9..000000000 --- a/src/components/Hero.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useMemo, type JSX } from "react"; -import { ArrowDown, ExternalLink, Zap } from "lucide-react"; -import { BRAND_SECONDARY_LINE_PARTS, BRAND_SLOGAN_PARTS, COMMUNITY_URL } from "@/data/constants"; - -export const Hero = (): JSX.Element => { - const fireflies = useMemo( - () => Array.from({ length: 8 }, (_, i) => <span key={i} className="firefly" />), - [], - ); - - return ( - <section aria-labelledby="hero-heading" className="relative flex min-h-dvh items-center justify-center px-6 pt-20"> - {/* Firefly particles */} - <div className="absolute inset-0 pointer-events-none" aria-hidden="true"> - {fireflies} - </div> - - <div className="relative z-10 mx-auto max-w-3xl text-center"> - <div className="animate-fade-up hero-badge mb-8 inline-flex items-center gap-2 rounded-full border border-primary/20 bg-[hsl(var(--surface))] px-4 py-1.5"> - <Zap size={12} aria-hidden="true" className="text-foreground" /> - <span className="text-sm text-foreground">{BRAND_SLOGAN_PARTS[0]}</span> - <Zap size={14} aria-hidden="true" className="text-foreground" /> - <span className="text-sm text-foreground">{BRAND_SLOGAN_PARTS[1]}</span> - <Zap size={14} aria-hidden="true" className="text-foreground" /> - <span className="text-sm text-foreground">{BRAND_SLOGAN_PARTS[2]}</span> - </div> - <h1 id="hero-heading" className="animate-fade-up-delay-1 font-heading text-4xl font-bold leading-tight tracking-tight sm:text-5xl md:text-6xl lg:text-7xl"> - <span className="block text-primary"> - {BRAND_SECONDARY_LINE_PARTS[0]} - </span> - <span className="block text-foreground"> - {BRAND_SECONDARY_LINE_PARTS[1]} - </span> - </h1> - <p className="animate-fade-up-delay-2 font-sans mx-auto mt-6 max-w-xl text-lg leading-relaxed text-dim"> - A welcoming community for open source enthusiasts. Learn through hands-on challenges, share your knowledge, and grow alongside people who love open source as much as you do. - </p> - <div className="animate-fade-up-delay-3 mt-10 flex flex-col sm:flex-row items-center justify-center gap-3"> - <a href="#challenges" className="btn-primary"> - Start a Challenge <ArrowDown size={16} aria-hidden="true" /> - </a> - <a - href={COMMUNITY_URL} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - className="btn-ghost" - > - Join the Community <ExternalLink size={16} aria-hidden="true" /> - </a> - </div> - </div> - </section> - ); -}; diff --git a/src/components/InlineProse.astro b/src/components/InlineProse.astro new file mode 100644 index 000000000..4a6d81d79 --- /dev/null +++ b/src/components/InlineProse.astro @@ -0,0 +1,15 @@ +--- +// Renders pre-rendered prose HTML with the right wrapper: block HTML → <div +// md-content>, inline HTML → <p md-inline>. Ported from src/components/InlineProse.tsx. +const BLOCK_ELEMENT_RE = /<(p|ul|ol|blockquote|h[1-6]|pre|table|hr|figure|div)\b/; + +interface Props { + html: string; + class?: string; +} +const { html, class: className } = Astro.props; +const isBlock = BLOCK_ELEMENT_RE.test(html); +const cls = [className?.trim(), isBlock ? "md-content" : "md-inline"].filter(Boolean).join(" "); +--- + +{isBlock ? <div class={cls} set:html={html} /> : <p class={cls} set:html={html} />} diff --git a/src/components/InlineProse.tsx b/src/components/InlineProse.tsx deleted file mode 100644 index 08afc3988..000000000 --- a/src/components/InlineProse.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { JSX } from "react"; - -// Nesting a block element inside <p> is invalid HTML; browsers auto-correct it -// in ways that diverge from React's VDOM, producing hydration error #418. -export const BLOCK_ELEMENT_RE = /<(p|ul|ol|blockquote|h[1-6]|pre|table|hr|figure|div)\b/; - -type InlineProseProps = { - html: string; - className?: string; -}; - -export const InlineProse = ({ html, className }: InlineProseProps): JSX.Element => { - // filter(Boolean) strips empty/whitespace-only className so no spurious - // leading space appears in the final class string. - const cls = (mdClass: string): string => - [className?.trim(), mdClass].filter(Boolean).join(" "); - if (BLOCK_ELEMENT_RE.test(html)) { - return ( - <div - className={cls("md-content")} - dangerouslySetInnerHTML={{ __html: html }} - /> - ); - } - return ( - <p - className={cls("md-inline")} - dangerouslySetInnerHTML={{ __html: html }} - /> - ); -}; diff --git a/src/components/LeaderboardList.astro b/src/components/LeaderboardList.astro new file mode 100644 index 000000000..4edc141d1 --- /dev/null +++ b/src/components/LeaderboardList.astro @@ -0,0 +1,34 @@ +--- +// Ranked player list with avatar, username, and optional points. Ported from +// src/components/LeaderboardList.tsx. Ranks are plain numbers, no medal icons. +import AvatarLink from "@/components/AvatarLink.astro"; + +interface Row { + rank: number; + username: string; + avatarUrl?: string; + points?: number; +} +interface Props { + rows: Row[]; + label?: string; +} +const { rows, label = "Ranked players" } = Astro.props; +--- + +<ol class="space-y-2.5" aria-label={label}> + {rows.map((row) => ( + <li class="flex items-center gap-3 text-sm"> + <span class="font-mono text-xs text-faint w-4 shrink-0 text-right" aria-hidden="true">{row.rank}</span> + <AvatarLink + username={row.username} + avatarUrl={row.avatarUrl} + size={24} + class="inline-flex items-center gap-1 font-medium text-foreground min-w-0 flex-1" + /> + {row.points != null && ( + <span class="shrink-0 font-mono text-xs font-semibold text-primary tabular-nums">{row.points} pts</span> + )} + </li> + ))} +</ol> diff --git a/src/components/LeaderboardList.tsx b/src/components/LeaderboardList.tsx deleted file mode 100644 index 055982bfb..000000000 --- a/src/components/LeaderboardList.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { CSSProperties, JSX } from "react"; -import { AvatarLink } from "@/components/AvatarLink"; - -export type LeaderboardEntry = { - rank: number; - username: string; - avatarUrl?: string; - points?: number; - /** Optional inline style for the avatar fallback span (e.g. palette colors). */ - avatarFallbackStyle?: CSSProperties; -}; - -type LeaderboardListProps = { - rows: LeaderboardEntry[]; - /** Accessible label for the ordered list. */ - label?: string; -}; - -/** - * Renders a ranked list of players with avatar, username, and optional points. - * Used by AdventureLeaderboard (adventure page sidebar) and CommunitySidebar - * (challenge detail sidebar). Ranks are plain numbers, no medal icons. - */ -export const LeaderboardList = ({ rows, label = "Ranked players" }: LeaderboardListProps): JSX.Element => ( - <ol className="space-y-2.5" aria-label={label}> - {rows.map((row) => ( - <li key={row.username} className="flex items-center gap-3 text-sm"> - <span - className="font-mono text-xs text-faint w-4 shrink-0 text-right" - aria-hidden="true" - > - {row.rank} - </span> - <AvatarLink - username={row.username} - avatarUrl={row.avatarUrl} - size={24} - avatarFallbackStyle={row.avatarFallbackStyle} - className="inline-flex items-center gap-1 font-medium text-foreground min-w-0 flex-1" - /> - {row.points != null && ( - <span className="shrink-0 font-mono text-xs font-semibold text-primary tabular-nums"> - {row.points} pts - </span> - )} - </li> - ))} - </ol> -); diff --git a/src/components/LevelCard.tsx b/src/components/LevelCard.tsx deleted file mode 100644 index 761fab366..000000000 --- a/src/components/LevelCard.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { JSX } from "react"; -import type { AdventureLevel } from "@/data/adventures"; -import { DifficultyBadge } from "@/components/DifficultyBadge"; -import { ExternalLink } from "lucide-react"; - -type LevelCardProps = { - level: AdventureLevel; - // Pass "none" when the parent page already renders the level name as h1, - // to avoid a duplicate heading in the document outline. - headingLevel?: "h2" | "none"; -} - -export const LevelCard = ({ level, headingLevel = "h2" }: LevelCardProps): JSX.Element => ( - <div className="card-glow rounded-xl border border-border bg-[hsl(var(--surface))] p-6"> - <span className="font-mono text-xs text-muted-foreground block mb-3">Challenge</span> - <div className="flex items-center gap-3 mb-4"> - <DifficultyBadge difficulty={level.difficulty} showDot /> - {headingLevel === "h2" ? ( - <h2 className="text-lg font-semibold text-foreground min-w-0 flex-1">{level.name}</h2> - ) : ( - <p className="text-lg font-semibold text-foreground min-w-0 flex-1">{level.name}</p> - )} - </div> - - <div className="mb-6"> - <p className="font-sans text-sm font-medium tracking-wide text-primary mb-3">Key Learnings</p> - <ul role="list" className="space-y-2"> - {level.learnings.map((learning) => ( - <li key={learning} className="flex items-start gap-2 text-sm text-muted-foreground"> - <span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-primary" aria-hidden="true" /> - <span className="min-w-0 md-inline" dangerouslySetInnerHTML={{ __html: learning }} /> - </li> - ))} - </ul> - </div> - - <a - href={level.codespacesUrl} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - className="btn-primary" - > - Open in GitHub Codespaces <ExternalLink size={14} aria-hidden="true" /> - </a> - <div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> - <p className="text-xs text-faint font-mono"> - Free GitHub account required - </p> - <a - href={level.discussionUrl} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - aria-label={`Discussion for ${level.name}`} - className="docs-ext-link text-xs font-medium" - > - Discussion <ExternalLink size={12} aria-hidden="true" /> - </a> - </div> - </div> -); diff --git a/src/components/LivePill.astro b/src/components/LivePill.astro new file mode 100644 index 000000000..f5f3c7b3b --- /dev/null +++ b/src/components/LivePill.astro @@ -0,0 +1,20 @@ +--- +interface Props { + class?: string; +} +const { class: className } = Astro.props; +--- + +<span + data-live-pill + class:list={[ + "inline-flex items-center gap-1.5 rounded-sm bg-primary px-2.5 py-1 font-mono text-xs uppercase tracking-wider text-primary-foreground", + className, + ]} +> + <span class="relative flex h-1.5 w-1.5" aria-hidden="true"> + <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary-foreground opacity-75"></span> + <span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-primary-foreground"></span> + </span> + Live +</span> diff --git a/src/components/LivePill.tsx b/src/components/LivePill.tsx deleted file mode 100644 index 41b757d74..000000000 --- a/src/components/LivePill.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { JSX } from "react"; -import { cn } from "@/lib/utils"; - -type LivePillProps = { className?: string }; - -export const LivePill = ({ className }: LivePillProps): JSX.Element => ( - <span className={cn("inline-flex items-center gap-1.5 rounded-sm bg-primary px-2.5 py-1 font-mono text-xs uppercase tracking-wider text-primary-foreground", className)}> - <span className="relative flex h-1.5 w-1.5" aria-hidden="true"> - <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary-foreground opacity-75" /> - <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-primary-foreground" /> - </span> - Live - </span> -); diff --git a/src/components/MarkdownContent.tsx b/src/components/MarkdownContent.tsx deleted file mode 100644 index cb30d838c..000000000 --- a/src/components/MarkdownContent.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useEffect, useRef, type JSX } from "react"; -import { useAbbrTooltips } from "@/hooks/useAbbrTooltips"; - -// SVG markup for copy button icons. Defined as module-level constants so the -// strings are created once rather than on every effect run. -const COPY_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`; -const CHECK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5"/></svg>`; - -type MarkdownContentProps = { - source: string; -}; - -export const MarkdownContent = ({ source }: MarkdownContentProps): JSX.Element => { - const ref = useRef<HTMLDivElement>(null); - const liveRef = useRef<HTMLSpanElement>(null); - - // Abbreviation tooltips share one implementation with the <Abbr> component. - useAbbrTooltips(ref, [source]); - - useEffect(() => { - const el = ref.current; - if (!el) return; - const liveEl = liveRef.current; - const cleanup: (() => void)[] = []; - - el.querySelectorAll("pre").forEach((pre) => { - const code = pre.querySelector("code"); - const langMatch = code?.className.match(/language-(\S+)/); - const langLabel = langMatch ? langMatch[1] : ""; - - // Header bar: language label left, copy button right - const header = document.createElement("div"); - header.className = "code-block-header"; - - const label = document.createElement("span"); - label.className = "code-lang-label"; - label.setAttribute("aria-hidden", "true"); - label.textContent = langLabel; - header.appendChild(label); - - const btn = document.createElement("button"); - btn.type = "button"; - btn.setAttribute("aria-label", "Copy code"); - btn.className = "code-header-btn"; - btn.innerHTML = `${COPY_SVG} Copy`; - header.appendChild(btn); - - // Code body: md-content code-block-body so .md-content pre styles apply - const codeBody = document.createElement("div"); - codeBody.className = "md-content code-block-body"; - - const wrapper = document.createElement("div"); - wrapper.className = "md-pre-group"; - codeBody.appendChild(wrapper); - - // Outer container replaces the pre in the DOM - const container = document.createElement("div"); - container.appendChild(header); - container.appendChild(codeBody); - - pre.parentNode?.insertBefore(container, pre); - wrapper.appendChild(pre); - - let resetTimer: ReturnType<typeof setTimeout> | null = null; - - const onClick = (): void => { - navigator.clipboard?.writeText(pre.textContent ?? "").then(() => { - btn.innerHTML = `${CHECK_SVG} Copied`; - btn.setAttribute("aria-label", "Code copied"); - if (liveEl) liveEl.textContent = "Code copied to clipboard"; - if (resetTimer !== null) clearTimeout(resetTimer); - resetTimer = setTimeout(() => { - btn.innerHTML = `${COPY_SVG} Copy`; - btn.setAttribute("aria-label", "Copy code"); - if (liveEl) liveEl.textContent = ""; - resetTimer = null; - }, 1500); - }).catch(() => {}); - }; - btn.addEventListener("click", onClick); - - cleanup.push(() => { - btn.removeEventListener("click", onClick); - if (resetTimer !== null) { - clearTimeout(resetTimer); - resetTimer = null; - } - if (liveEl) liveEl.textContent = ""; - if (container.parentNode) { - container.parentNode.insertBefore(pre, container); - container.remove(); - } - }); - }); - - return () => cleanup.forEach((fn) => fn()); - }, [source]); - - return ( - <> - {/* Polite live region announces copy success to screen readers that - don't re-read a focused button's aria-label when it changes. */} - <span ref={liveRef} aria-live="polite" aria-atomic="true" className="sr-only" /> - <div - ref={ref} - className="font-sans md-content" - dangerouslySetInnerHTML={{ __html: source }} - /> - </> - ); -}; diff --git a/src/components/MobileMenu.astro b/src/components/MobileMenu.astro new file mode 100644 index 000000000..13b4a47a4 --- /dev/null +++ b/src/components/MobileMenu.astro @@ -0,0 +1,221 @@ +--- +import IconMenu from "~icons/lucide/menu"; +import IconX from "~icons/lucide/x"; +import IconExternalLink from "~icons/lucide/external-link"; + +// Mobile nav drawer. Static markup plus one script; no island. +// +// The drawer is always in the DOM so `aria-controls` always resolves, and it +// carries `hidden` while closed. `aria-current` is resolved at build time from +// Astro.url.pathname by the caller, rather than re-derived in the browser: the +// server already knows the current route, so the SSR markup is correct for +// assistive tech before any script runs. + +export interface NavLink { + href: string; + label: string; + external?: boolean; + active?: boolean; +} + +interface Props { + links: NavLink[]; +} +const { links } = Astro.props; + +// Matches the desktop nav link styling minus the animated underline, which the +// drawer does not use. +const linkCls = + "inline-flex items-center gap-1 min-h-[44px] text-sm font-medium text-dim hover:text-foreground dark:hover:text-primary transition-colors rounded px-1.5 -mx-1.5 focus-ring"; +--- + +{/* `group` + the aria-expanded variant keeps the icon in step with the state + the assistive tech sees, so there is only one thing for the script to set. */} +<button + type="button" + data-mobile-menu-trigger + class="group flex h-11 w-11 items-center justify-center rounded-md border border-border bg-[hsl(var(--surface))] text-foreground/70 hover:text-foreground transition-all focus-ring" + aria-label="Menu" + aria-expanded="false" + aria-controls="mobile-menu" +> + <IconMenu width={18} height={18} aria-hidden="true" class="group-aria-expanded:hidden" /> + <IconX width={18} height={18} aria-hidden="true" class="hidden group-aria-expanded:block" /> +</button> + +{/* Positioned absolute against the fixed <nav>, which is its containing block, + so it sits directly below the bar at full width. A plain <div>, not <nav>: + it lives inside <nav aria-label="Main"> and nesting a second navigation + landmark would create two overlapping regions. */} +<div + id="mobile-menu" + hidden + data-mobile-menu + class="absolute inset-x-0 top-full z-40 border-b border-border bg-background px-6 py-2 md:hidden" +> + <ul role="list" class="contents"> + { + links.map((link) => ( + <li class="contents"> + {link.external ? ( + <a + href={link.href} + target="_blank" + rel="noopener noreferrer" + aria-describedby="new-tab-hint" + class={linkCls} + > + {link.label} <IconExternalLink width={12} height={12} aria-hidden="true" /> + </a> + ) : ( + <a + href={link.href} + class:list={[linkCls, link.active && "font-semibold text-foreground"]} + aria-current={link.active ? "page" : undefined} + > + {link.label} + </a> + )} + </li> + )) + } + </ul> +</div> + +<script> + const DESKTOP_MQ = "(min-width: 768px)"; + + // The full set from the pre-migration focus trap. The drawer only holds links + // today, but a trap that silently ignores a form control it does not recognise + // is a trap with a hole in it. + const FOCUSABLE = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[contenteditable]:not([contenteditable="false"])', + '[tabindex]:not([tabindex="-1"])', + ].join(", "); + + // Classes that make the drawer a column when open. Applied by script because + // Tailwind's `flex` sets display:flex, which would defeat the `hidden` + // attribute if both were present at once. + const OPEN_CLASSES = ["flex", "flex-col", "gap-1"]; + + let teardown: (() => void) | null = null; + + function initMobileMenu(): void { + teardown?.(); + teardown = null; + + const trigger = document.querySelector<HTMLButtonElement>("[data-mobile-menu-trigger]"); + const drawer = document.querySelector<HTMLElement>("[data-mobile-menu]"); + if (!trigger || !drawer) return; + + let inertSiblings: HTMLElement[] = []; + + const isOpen = (): boolean => trigger.getAttribute("aria-expanded") === "true"; + + const focusablesIn = (container: HTMLElement): HTMLElement[] => + Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE)); + + // Hide every body child except the drawer's own top-level ancestor, so the + // page behind the drawer is unreachable by pointer, focus and screen reader. + const setSiblingsInert = (): void => { + let host: Element | null = drawer; + while (host && host.parentElement !== document.body) host = host.parentElement; + if (!host) return; + inertSiblings = Array.from(document.body.children).filter( + (el): el is HTMLElement => el instanceof HTMLElement && el !== host, + ); + inertSiblings.forEach((el) => { + el.setAttribute("inert", ""); + el.setAttribute("aria-hidden", "true"); + }); + }; + + const clearSiblingsInert = (): void => { + inertSiblings.forEach((el) => { + el.removeAttribute("inert"); + el.removeAttribute("aria-hidden"); + }); + inertSiblings = []; + }; + + const onKeydown = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + close(true); + return; + } + if (event.key !== "Tab") return; + + const items = focusablesIn(drawer); + if (items.length === 0) return; + const first = items[0]; + const last = items[items.length - 1]; + + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + const open = (): void => { + trigger.setAttribute("aria-expanded", "true"); + drawer.hidden = false; + drawer.classList.add(...OPEN_CLASSES); + setSiblingsInert(); + document.addEventListener("keydown", onKeydown); + focusablesIn(drawer)[0]?.focus(); + }; + + // restoreFocus is false when focus is already going somewhere the user chose + // (a drawer link, a navigation), where pulling it back would be a steal. + const close = (restoreFocus: boolean): void => { + trigger.setAttribute("aria-expanded", "false"); + drawer.hidden = true; + drawer.classList.remove(...OPEN_CLASSES); + document.removeEventListener("keydown", onKeydown); + clearSiblingsInert(); + if (restoreFocus) trigger.focus(); + }; + + const onTriggerClick = (): void => { + if (isOpen()) close(true); + else open(); + }; + + const onDrawerClick = (event: MouseEvent): void => { + if ((event.target as Element | null)?.closest("a")) close(false); + }; + + // Crossing to desktop hides the drawer via CSS, which would otherwise strand + // the trap and the inert background with no way to reach the trigger. + const desktopMq = window.matchMedia(DESKTOP_MQ); + const onBreakpoint = (event: MediaQueryListEvent): void => { + if (event.matches && isOpen()) close(false); + }; + + trigger.addEventListener("click", onTriggerClick); + drawer.addEventListener("click", onDrawerClick); + desktopMq.addEventListener("change", onBreakpoint); + + teardown = () => { + close(false); + trigger.removeEventListener("click", onTriggerClick); + drawer.removeEventListener("click", onDrawerClick); + desktopMq.removeEventListener("change", onBreakpoint); + }; + } + + document.addEventListener("astro:page-load", initMobileMenu); + // Drop the keydown listener and any inert attributes before the DOM is swapped. + document.addEventListener("astro:before-swap", () => { + teardown?.(); + teardown = null; + }); +</script> diff --git a/src/components/NavLink.tsx b/src/components/NavLink.tsx deleted file mode 100644 index 83c8efbae..000000000 --- a/src/components/NavLink.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { NavLink as RouterNavLink, NavLinkProps } from "react-router"; -import { forwardRef, type ForwardRefExoticComponent, type RefAttributes } from "react"; -import { cn } from "@/lib/utils"; - -type NavLinkCompatProps = Omit<NavLinkProps, "className"> & { - className?: string; - activeClassName?: string; - pendingClassName?: string; -} - -// React Router's NavLink automatically sets aria-current="page" on the rendered -// <a> when isActive is true. Do not replace RouterNavLink with a plain <a> or -// <Link> — doing so removes the aria-current injection. -const NavLink: ForwardRefExoticComponent<NavLinkCompatProps & RefAttributes<HTMLAnchorElement>> = forwardRef<HTMLAnchorElement, NavLinkCompatProps>( - ({ className, activeClassName, pendingClassName, to, ...props }, ref) => { - return ( - <RouterNavLink - ref={ref} - to={to} - className={({ isActive, isPending }) => - cn(className, isActive && activeClassName, isPending && pendingClassName) - } - {...props} - /> - ); - }, -); - -NavLink.displayName = "NavLink"; - -export { NavLink }; diff --git a/src/components/Navbar.astro b/src/components/Navbar.astro new file mode 100644 index 000000000..81332c5b4 --- /dev/null +++ b/src/components/Navbar.astro @@ -0,0 +1,76 @@ +--- +import IconExternalLink from "~icons/lucide/external-link"; +import { SITE_NAME, COMMUNITY_URL } from "@/lib/site"; +import ThemeToggle from "@/components/ThemeToggle.astro"; +import MobileMenu from "@/components/MobileMenu.astro"; + +const base = import.meta.env.BASE_URL; +const path = Astro.url.pathname; +const logoDark = `${base}brand/offon-logo-dark-color.svg`; +const logoLight = `${base}brand/offon-logo-light-mono.svg`; + +// Community sits between About and Contribute and is an external link. The same +// list feeds the desktop cluster and the mobile drawer, so it is defined once. +const navItems = [ + { href: `${base}challenges/`, label: "Challenges" }, + { href: `${base}about/`, label: "About" }, + { href: COMMUNITY_URL, label: "Community", external: true }, + { href: `${base}contribute/`, label: "Contribute" }, + { href: `${base}handbook/`, label: "Handbook" }, + { href: `${base}sponsors/`, label: "Sponsors" }, +]; + +const isActive = (item: (typeof navItems)[number]): boolean => + !item.external && path.startsWith(item.href); + +// min-h-[44px] keeps links at the WCAG 2.5.8 touch-target size. The underline is +// transparent until hover/active, giving the animated underline affordance. +const linkCls = + "inline-flex items-center gap-1 min-h-[44px] text-sm font-medium text-dim hover:text-foreground dark:hover:text-primary transition-colors underline underline-offset-4 decoration-[3px] decoration-transparent rounded px-1.5 -mx-1.5 focus-ring"; +const activeCls = + "text-foreground dark:text-primary underline decoration-foreground dark:decoration-primary underline-offset-4"; +--- + +<header> +<nav aria-label="Main" class="fixed top-0 left-0 right-0 z-50 border-b border-border bg-background"> + <div class="mx-auto flex max-w-6xl items-center justify-between px-6 py-1.5"> + <a href={base} aria-label={`${SITE_NAME} home`} class="logo-link flex items-center focus-ring rounded-sm"> + {/* Dark logo is high-priority: visible in the default (dark) theme. Light logo is hidden until the user switches. */} + <img src={logoDark} alt="" width={130} height={33} loading="eager" fetchpriority="high" class="h-8 hidden dark:block" /> + <img src={logoLight} alt="" width={130} height={33} loading="eager" class="h-8 block dark:hidden" /> + </a> + + {/* Desktop nav */} + <div class="hidden md:flex items-center gap-8"> + <ul role="list" class="contents"> + {navItems.map((item) => + item.external ? ( + <li class="contents"> + <a href={item.href} target="_blank" rel="noopener noreferrer" aria-describedby="new-tab-hint" class={linkCls}> + {item.label} <IconExternalLink width={12} height={12} aria-hidden="true" /> + </a> + </li> + ) : ( + <li class="contents"> + <a + href={item.href} + class={`${linkCls} ${isActive(item) ? activeCls : ""}`} + aria-current={isActive(item) ? "page" : undefined} + > + {item.label} + </a> + </li> + ), + )} + </ul> + <ThemeToggle variant="desktop" /> + </div> + + {/* Mobile: theme toggle + hamburger drawer */} + <div class="flex md:hidden items-center gap-3"> + <ThemeToggle variant="mobile" /> + <MobileMenu links={navItems.map((item) => ({ ...item, active: isActive(item) }))} /> + </div> + </div> +</nav> +</header> diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx deleted file mode 100644 index ae04f726c..000000000 --- a/src/components/Navbar.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { useState, useEffect, useRef, type JSX } from "react"; -import { Link } from "react-router"; -import { Sun, Moon, Menu, X, ExternalLink } from "lucide-react"; -import { NavLink } from "@/components/NavLink"; -import { useTheme } from "@/hooks/useTheme"; -import { COMMUNITY_URL, SITE_NAME } from "@/data/constants"; -import { cn } from "@/lib/utils"; -import { useEscapeKey } from "@/hooks/useEscapeKey"; -import { useFocusTrap } from "@/hooks/useFocusTrap"; -const logoDark = `${import.meta.env.BASE_URL}brand/offon-logo-dark-color.svg`; -const logoLight = `${import.meta.env.BASE_URL}brand/offon-logo-light-mono.svg`; - -const linkCls = "inline-flex items-center gap-1 min-h-[44px] text-sm font-medium text-dim hover:text-foreground dark:hover:text-primary transition-colors underline underline-offset-4 decoration-[3px] decoration-transparent rounded px-1.5 -mx-1.5 focus-ring"; -const activeCls = "text-foreground dark:text-primary underline decoration-foreground dark:decoration-primary underline-offset-4"; - -type NavThemeToggleProps = { theme: "dark" | "light"; onToggle: () => void; className?: string }; - -const NavThemeToggle = ({ theme, onToggle, className }: NavThemeToggleProps): JSX.Element => ( - <button - onClick={onToggle} - className={cn( - "flex h-11 w-11 items-center justify-center rounded-md border border-border bg-[hsl(var(--surface))] text-foreground/70 hover:text-foreground transition-all focus-ring", - className - )} - aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"} - > - {theme === "dark" ? <Sun size={18} aria-hidden="true" /> : <Moon size={18} aria-hidden="true" />} - </button> -); - -type NavLinksProps = { - onNavigate?: () => void; -}; - -const NavLinks = ({ onNavigate }: NavLinksProps): JSX.Element => ( - <ul role="list" className="contents"> - <li className="contents"> - <NavLink to="/challenges/" className={linkCls} activeClassName={activeCls} onClick={onNavigate}>Challenges</NavLink> - </li> - <li className="contents"> - <NavLink to="/about/" className={linkCls} activeClassName={activeCls} onClick={onNavigate}>About</NavLink> - </li> - <li className="contents"> - <a - href={COMMUNITY_URL} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - className={linkCls} - onClick={onNavigate} - > - Community <ExternalLink size={12} aria-hidden="true" /> - </a> - </li> - <li className="contents"> - <NavLink to="/contribute/" className={linkCls} activeClassName={activeCls} onClick={onNavigate}>Contribute</NavLink> - </li> - <li className="contents"> - <NavLink to="/handbook/" className={linkCls} activeClassName={activeCls} onClick={onNavigate}>Handbook</NavLink> - </li> - <li className="contents"> - <NavLink to="/sponsors/" className={linkCls} activeClassName={activeCls} onClick={onNavigate}>Sponsors</NavLink> - </li> - </ul> -); - -export const Navbar = (): JSX.Element => { - const { theme, toggle } = useTheme(); - const [menuOpen, setMenuOpen] = useState(false); - - const triggerRef = useRef<HTMLButtonElement>(null); - const menuRef = useRef<HTMLDivElement>(null); - - const closeMenu = (restoreFocus = true): void => { - setMenuOpen(false); - if (restoreFocus) triggerRef.current?.focus(); - }; - - // Two keydown listeners are registered while the menu is open: useEscapeKey - // handles Escape, useFocusTrap handles Tab wrapping. Keeping them separate - // preserves each hook's single responsibility and reusability elsewhere. - useEscapeKey(closeMenu, menuOpen); - useFocusTrap(menuRef, menuOpen); - - // Hide all body siblings from AT while the menu overlay is open. - // Iterating document.body.children covers every sibling (main, footer, - // consent banner, skip-nav, etc.) rather than only named landmarks. - // Walking from the menu element to its nearest body-child ancestor - // correctly excludes the nav in production and the React Testing Library - // container in tests, so the menu stays operable while the rest is inert. - // aria-hidden alongside inert provides defence in depth for AT that does - // not yet fully honour the inert attribute (older JAWS/VoiceOver versions). - useEffect(() => { - if (!menuOpen) return; - let host: Element | null = menuRef.current; - while (host && host.parentElement !== document.body) { - host = host.parentElement; - } - const siblings = Array.from(document.body.children).filter( - (el) => el !== host - ) as HTMLElement[]; - siblings.forEach((el) => { - el.setAttribute("inert", ""); - el.setAttribute("aria-hidden", "true"); - }); - return () => { - siblings.forEach((el) => { - el.removeAttribute("inert"); - el.removeAttribute("aria-hidden"); - }); - }; - }, [menuOpen]); - - return ( - <nav - aria-label="Main" - className="fixed top-0 left-0 right-0 z-50 border-b border-border bg-background" - > - <div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-1.5"> - <Link to="/" aria-label={`${SITE_NAME} home`} className="logo-link flex items-center focus-ring rounded-sm"> - {/* Dark logo is high-priority: it's visible in the default (dark) theme. Light logo uses auto priority since it's hidden until the user switches theme. */} - <img src={logoDark} alt="" width={130} height={33} loading="eager" fetchPriority="high" className="h-8 dark:block hidden" /> - <img src={logoLight} alt="" width={130} height={33} loading="eager" className="h-8 block dark:hidden" /> - </Link> - - {/* Desktop nav */} - <div className="hidden md:flex items-center gap-8"> - <NavLinks /> - <NavThemeToggle theme={theme} onToggle={toggle} className="hover:border-primary/30" /> - </div> - - {/* Mobile: theme toggle + hamburger */} - <div className="flex md:hidden items-center gap-3"> - <NavThemeToggle theme={theme} onToggle={toggle} /> - <button - ref={triggerRef} - onClick={() => setMenuOpen((o) => !o)} - className="flex h-11 w-11 items-center justify-center rounded-md border border-border bg-[hsl(var(--surface))] text-foreground/70 hover:text-foreground transition-all focus-ring" - aria-label="Menu" - aria-expanded={menuOpen} - aria-controls="mobile-menu" - > - {menuOpen ? <X size={18} aria-hidden="true" /> : <Menu size={18} aria-hidden="true" />} - </button> - </div> - </div> - - {/* Mobile menu drawer — always in the DOM so aria-controls has a valid target. - Plain <div>, not <nav>: this sits inside the outer <nav aria-label="Main"> - and a nested nav landmark would create two overlapping navigation regions. */} - <div - ref={menuRef} - id="mobile-menu" - hidden={!menuOpen} - className={cn( - "md:hidden border-t border-border bg-background px-6 py-2", - menuOpen && "flex flex-col gap-1" - )} - > - <NavLinks onNavigate={() => closeMenu(false)} /> - </div> - </nav> - ); -}; diff --git a/src/components/NotFoundPage.tsx b/src/components/NotFoundPage.tsx deleted file mode 100644 index c418bbb21..000000000 --- a/src/components/NotFoundPage.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { JSX } from "react"; -import { Link } from "react-router"; -import { Navbar } from "@/components/Navbar"; -import { Footer } from "@/components/Footer"; - -type NotFoundPageProps = { - title: string; - message: string; -}; - -export const NotFoundPage = ({ title, message }: NotFoundPageProps): JSX.Element => ( - <div className="min-h-dvh bg-background"> - <Navbar /> - <main id="main-content" tabIndex={-1} className="flex min-h-[80vh] flex-col items-center justify-center px-6 text-center"> - <h1 className="text-2xl font-bold text-foreground mb-3">{title}</h1> - <p className="text-muted-foreground mb-6">{message}</p> - <Link - to="/" - className="text-sm font-medium text-primary underline decoration-2 underline-offset-2 hover:text-foreground transition-colors focus-ring-tight rounded-sm" - > - Go to Homepage - </Link> - </main> - <Footer /> - </div> -); diff --git a/src/components/OtherLevelsCard.astro b/src/components/OtherLevelsCard.astro new file mode 100644 index 000000000..5a0d1caed --- /dev/null +++ b/src/components/OtherLevelsCard.astro @@ -0,0 +1,76 @@ +--- +// Ported from src/components/OtherLevelsCard.tsx. +import IconArrowRight from "~icons/lucide/arrow-right"; +import { difficultyStyle, type Difficulty } from "@/lib/difficulty"; + +type LevelRef = { id: string; name: string; difficulty: Difficulty }; +type UpcomingRef = { name: string; difficulty: Difficulty }; + +interface Props { + adventure: { + slug: string; + title: string; + levels: LevelRef[]; + upcomingLevels?: UpcomingRef[]; + }; + currentLevelId: string; +} +const { adventure, currentLevelId } = Astro.props; +const base = import.meta.env.BASE_URL; +const otherLevels = adventure.levels.filter((l) => l.id !== currentLevelId); +const upcoming = adventure.upcomingLevels ?? []; +--- + +{ + (otherLevels.length > 0 || upcoming.length > 0) && ( + <div class="rounded-xl border border-border bg-[hsl(var(--surface))] p-5"> + <h2 class="font-sans text-base font-semibold text-foreground mb-4">More Levels</h2> + + <ul role="list" class="space-y-2"> + {otherLevels.map((level) => ( + <li> + <a + href={`${base}adventures/${adventure.slug}/levels/${level.id}/`} + aria-label={`${level.name} – ${level.difficulty} level of ${adventure.title}`} + class="group inline-flex w-full items-center gap-2 rounded-sm border px-2.5 py-1.5 text-xs no-underline hover:brightness-95 transition-[filter] focus-ring-tight" + style={difficultyStyle(level.difficulty)} + > + <span class="shrink-0 uppercase font-medium">{level.difficulty}</span> + <span aria-hidden="true" class="inline-block w-px h-3 bg-current opacity-40" /> + <span class="flex-1 truncate">{level.name}</span> + <IconArrowRight + width={11} + height={11} + class="shrink-0 opacity-50 transition-opacity group-hover:opacity-100" + aria-hidden="true" + /> + </a> + </li> + ))} + + {upcoming.map((level) => { + const hasDistinctName = level.name.toLowerCase() !== level.difficulty.toLowerCase(); + return ( + <li> + <span + class="inline-flex w-full items-center gap-2 rounded-sm border border-dashed px-2.5 py-1.5 text-xs" + style={difficultyStyle(level.difficulty)} + > + <span class="shrink-0 uppercase font-medium">{level.difficulty}</span> + {hasDistinctName ? ( + <> + <span aria-hidden="true" class="inline-block w-px h-3 bg-current opacity-40" /> + <span class="flex-1 truncate">{level.name}</span> + </> + ) : ( + <span class="flex-1" /> + )} + <span class="shrink-0 text-xs uppercase tracking-widest">Soon</span> + </span> + </li> + ); + })} + </ul> + </div> + ) +} diff --git a/src/components/OtherLevelsCard.tsx b/src/components/OtherLevelsCard.tsx deleted file mode 100644 index 0e4bfdfd0..000000000 --- a/src/components/OtherLevelsCard.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type { JSX } from "react"; -import { Link } from "react-router"; -import { ArrowRight } from "lucide-react"; -import type { Adventure } from "@/data/adventures"; -import { difficultyStyle } from "@/lib/difficulty"; - -type OtherLevelsCardProps = { - adventure: Adventure; - currentLevelId: string; -}; - -export const OtherLevelsCard = ({ - adventure, - currentLevelId, -}: OtherLevelsCardProps): JSX.Element | null => { - const otherLevels = adventure.levels.filter((l) => l.id !== currentLevelId); - const upcoming = adventure.upcomingLevels ?? []; - - if (otherLevels.length === 0 && upcoming.length === 0) return null; - - return ( - <div className="rounded-xl border border-border bg-[hsl(var(--surface))] p-5"> - <h2 className="font-sans text-base font-semibold text-foreground mb-4"> - More Levels - </h2> - - <ul role="list" className="space-y-2"> - {otherLevels.map((level) => ( - <li key={level.id}> - <Link - to={`/adventures/${adventure.id}/levels/${level.id}/`} - aria-label={`${level.name} – ${level.difficulty} level of ${adventure.title}`} - className="group inline-flex w-full items-center gap-2 rounded-sm border px-2.5 py-1.5 text-xs no-underline hover:brightness-95 transition-[filter] focus-ring-tight" - style={difficultyStyle(level.difficulty)} - > - <span className="shrink-0 uppercase font-medium">{level.difficulty}</span> - <span aria-hidden="true" className="inline-block w-px h-3 bg-current opacity-40" /> - <span className="flex-1 truncate">{level.name}</span> - <ArrowRight - size={11} - className="shrink-0 opacity-50 transition-opacity group-hover:opacity-100" - aria-hidden="true" - /> - </Link> - </li> - ))} - - {upcoming.map((level) => { - const hasDistinctName = level.name.toLowerCase() !== level.difficulty.toLowerCase(); - return ( - <li key={`upcoming-${level.difficulty}-${level.name}`}> - <span - className="inline-flex w-full items-center gap-2 rounded-sm border border-dashed px-2.5 py-1.5 text-xs" - style={difficultyStyle(level.difficulty)} - > - <span className="shrink-0 uppercase font-medium">{level.difficulty}</span> - {hasDistinctName ? ( - <> - <span aria-hidden="true" className="inline-block w-px h-3 bg-current opacity-40" /> - <span className="flex-1 truncate">{level.name}</span> - </> - ) : ( - <span className="flex-1" /> - )} - <span className="shrink-0 text-xs uppercase tracking-widest">Soon</span> - </span> - </li> - ); - })} - </ul> - </div> - ); -}; diff --git a/src/components/PageHero.astro b/src/components/PageHero.astro new file mode 100644 index 000000000..20c211ec1 --- /dev/null +++ b/src/components/PageHero.astro @@ -0,0 +1,56 @@ +--- +// Full-width primary hero used on the Adventures and Challenges landing pages +// (the static content pages inline the same markup). pt-32 clears the fixed nav. +interface Cta { + label: string; + href: string; + external?: boolean; +} +interface Props { + eyebrow?: string; + title: string; + description: string; + primaryCta?: Cta; + secondaryCta?: Cta; +} +const { eyebrow, title, description, primaryCta, secondaryCta } = Astro.props; +const base = import.meta.env.BASE_URL; + +const resolveHref = (cta: Cta): string => + cta.external || /^(mailto:|#|https?:)/.test(cta.href) ? cta.href : base + cta.href.replace(/^\//, ""); + +const ctas = [ + primaryCta ? { cta: primaryCta, cls: "btn-inverse" } : null, + secondaryCta ? { cta: secondaryCta, cls: "btn-ghost-inverse" } : null, +].filter((x): x is { cta: Cta; cls: string } => x !== null); +--- + +<section + aria-labelledby="page-hero-heading" + class="bg-primary pt-32 pb-20 px-6 md:px-16 overflow-hidden min-h-[560px] flex flex-col justify-center" +> + <div class="mx-auto max-w-6xl relative w-full"> + <div class="max-w-2xl"> + {eyebrow && ( + <span class="font-sans text-sm font-medium uppercase tracking-widest text-background/90 block mb-4">{eyebrow}</span> + )} + <h1 id="page-hero-heading" class="text-4xl md:text-5xl font-bold leading-tight tracking-tight text-primary-foreground mb-5"> + {title} + </h1> + <p class="font-sans text-base leading-relaxed text-background/90 max-w-2xl mb-8">{description}</p> + {ctas.length > 0 && ( + <div class="flex gap-3 flex-wrap"> + {ctas.map(({ cta, cls }) => + cta.external ? ( + <a href={resolveHref(cta)} target="_blank" rel="noopener noreferrer" aria-describedby="new-tab-hint" class={cls}> + {cta.label} + </a> + ) : ( + <a href={resolveHref(cta)} class={cls}>{cta.label}</a> + ), + )} + </div> + )} + </div> + </div> +</section> diff --git a/src/components/PageHero.tsx b/src/components/PageHero.tsx deleted file mode 100644 index 099dd7b3a..000000000 --- a/src/components/PageHero.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { type ReactNode, type JSX } from "react"; -import { Link } from "react-router"; - -type Cta = { - label: ReactNode; - href: string; - external?: boolean; -} - -type PageHeroProps = { - eyebrow?: string; - title: string; - description: string; - primaryCta?: Cta; - secondaryCta?: Cta; -} - -const renderCta = (cta: Cta, isPrimary: boolean): JSX.Element => { - const primaryCls = "btn-inverse"; - const secondaryCls = "btn-ghost-inverse"; - const cls = isPrimary ? primaryCls : secondaryCls; - - if (cta.external) { - return ( - <a key={cta.href} href={cta.href} target="_blank" rel="noopener noreferrer" aria-describedby="new-tab-hint" className={cls}> - {cta.label} - - </a> - ); - } - if (cta.href.startsWith("mailto:") || cta.href.startsWith("#")) { - return ( - <a key={cta.href} href={cta.href} className={cls}> - {cta.label} - </a> - ); - } - return ( - <Link key={cta.href} to={cta.href} className={cls}> - {cta.label} - </Link> - ); -}; - -export const PageHero = ({ eyebrow, title, description, primaryCta, secondaryCta }: PageHeroProps): JSX.Element => { - return ( - <section aria-labelledby="page-hero-heading" className="bg-primary pt-32 pb-20 px-6 md:px-16 overflow-hidden min-h-[560px] flex flex-col justify-center"> - <div className="mx-auto max-w-6xl relative w-full"> - <div className="max-w-2xl"> - {eyebrow && ( - <span className="font-sans text-sm font-medium uppercase tracking-widest text-background/90 block mb-4"> - {eyebrow} - </span> - )} - <h1 id="page-hero-heading" className="text-4xl md:text-5xl font-bold leading-tight tracking-tight text-primary-foreground mb-5"> - {title} - </h1> - <p className="font-sans text-base leading-relaxed text-background/90 max-w-2xl mb-8"> - {description} - </p> - {(primaryCta || secondaryCta) && ( - <div className="flex gap-3 flex-wrap"> - {primaryCta && renderCta(primaryCta, true)} - {secondaryCta && renderCta(secondaryCta, false)} - </div> - )} - </div> - </div> - </section> - ); -}; diff --git a/src/components/PersonNameLink.astro b/src/components/PersonNameLink.astro new file mode 100644 index 000000000..fd02ac140 --- /dev/null +++ b/src/components/PersonNameLink.astro @@ -0,0 +1,26 @@ +--- +import IconExternalLink from "~icons/lucide/external-link"; + +interface Props { + name: string; + url?: string; +} +const { name, url } = Astro.props; +--- + +{ + url ? ( + <a + href={url} + target="_blank" + rel="noopener noreferrer" + aria-describedby="new-tab-hint" + class="docs-ext-link text-base font-semibold" + > + {name} + <IconExternalLink width={12} height={12} aria-hidden="true" /> + </a> + ) : ( + <span class="text-base font-semibold text-foreground">{name}</span> + ) +} diff --git a/src/components/PersonNameLink.tsx b/src/components/PersonNameLink.tsx deleted file mode 100644 index 86898e9de..000000000 --- a/src/components/PersonNameLink.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { JSX } from "react"; -import { ExternalLink } from "lucide-react"; - -type PersonNameLinkProps = { - name: string; - url?: string; -}; - -export const PersonNameLink = ({ name, url }: PersonNameLinkProps): JSX.Element => { - if (!url) { - return <span className="text-base font-semibold text-foreground">{name}</span>; - } - - return ( - <a - href={url} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - className="docs-ext-link text-base font-semibold" - > - {name} - <ExternalLink size={12} aria-hidden="true" /> - </a> - ); -}; diff --git a/src/components/RewardsCard.astro b/src/components/RewardsCard.astro new file mode 100644 index 000000000..c5c9748a6 --- /dev/null +++ b/src/components/RewardsCard.astro @@ -0,0 +1,107 @@ +--- +// Ported from src/components/RewardsCard.tsx, including the compact sidebar +// variant used by the challenge detail page. +import IconTrophy from "~icons/lucide/trophy"; +import IconExternalLink from "~icons/lucide/external-link"; +import InlineProse from "@/components/InlineProse.astro"; +import { COMMUNITY_URL } from "@/lib/site"; +import { formatDeadline } from "@/lib/utils"; + +interface Props { + rewards: { + deadline: string; + eligibility: string; + tiers: { label: string; description: string }[]; + rankingNote?: string; + rankingRulesUrl?: string; + }; + compact?: boolean; + /** Collection levels render `deadline` as string | null | undefined. */ + levelDeadline?: string | null; + deadlinePast?: boolean; +} +const { rewards, compact = false, levelDeadline, deadlinePast = false } = Astro.props; +const deadlineShown = compact ? levelDeadline : rewards.deadline; +--- + +<div class="rounded-xl border border-primary/30 bg-[hsl(var(--surface))] p-5"> + <div class="flex items-center gap-2 mb-4"> + <IconTrophy width={15} height={15} class="text-primary shrink-0" aria-hidden="true" /> + <h2 class="font-sans text-base font-semibold text-foreground">Rewards</h2> + </div> + + { + !compact && + (deadlinePast ? ( + <div class="text-xs text-dim leading-relaxed mb-4 space-y-2"> + <p>The deadline has passed, but the adventure is still open. Play it, post your solution in the community.</p> + <p> + If you enjoyed the adventure and want to share what you learned, write a tutorial in{" "} + <a + href={`${COMMUNITY_URL}/c/community-voices/38`} + target="_blank" + rel="noopener noreferrer" + aria-describedby="new-tab-hint" + class="docs-ext-link" + > + Community Voices + <IconExternalLink width={10} height={10} aria-hidden="true" /> + </a> + . + </p> + </div> + ) : ( + <p class="text-xs text-dim leading-relaxed mb-4"> + <span class="md-inline" set:html={rewards.eligibility} /> + </p> + )) + } + + { + compact && deadlinePast && ( + <p class="text-xs text-dim leading-relaxed mb-3">Deadline passed. Adventure still open.</p> + ) + } + + <div class:list={["space-y-2", !compact && "mb-4"]}> + { + rewards.tiers.map((tier) => ( + <div> + <p class="text-xs font-semibold text-foreground">{tier.label}</p> + <InlineProse html={tier.description} class="text-xs text-dim" /> + </div> + )) + } + </div> + + { + !compact && rewards.rankingNote && ( + <p class="text-xs text-faint leading-relaxed mt-4"> + <span class="md-inline" set:html={rewards.rankingNote} />{" "} + {rewards.rankingRulesUrl && ( + <a + href={rewards.rankingRulesUrl} + target="_blank" + rel="noopener noreferrer" + aria-describedby="new-tab-hint" + class="docs-ext-link" + > + See the points & ranking rules for the full breakdown + <IconExternalLink width={10} height={10} aria-hidden="true" /> + </a> + )} + </p> + ) + } + + { + deadlineShown && ( + <> + <div class="border-t border-border my-3"></div> + <p class="text-xs text-faint"> + Deadline: <span class="font-medium text-foreground">{formatDeadline(deadlineShown)}</span> + </p> + </> + ) + } +</div> diff --git a/src/components/RewardsCard.tsx b/src/components/RewardsCard.tsx deleted file mode 100644 index 1218be623..000000000 --- a/src/components/RewardsCard.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { type JSX } from "react"; -import { ExternalLink, Trophy } from "lucide-react"; -import type { AdventureRewards } from "@/data/adventures/types"; -import { COMMUNITY_URL } from "@/data/constants"; -import { formatDeadline } from "@/lib/utils"; -import { InlineProse } from "@/components/InlineProse"; - -type RewardsCardProps = { - rewards: AdventureRewards; - compact?: boolean; - levelDeadline?: string; - deadlinePast?: boolean; -}; - -export const RewardsCard = ({ rewards, compact = false, levelDeadline, deadlinePast = false }: RewardsCardProps): JSX.Element => ( - <div className="rounded-xl border border-primary/30 bg-[hsl(var(--surface))] p-5"> - <div className="flex items-center gap-2 mb-4"> - <Trophy size={15} className="text-primary shrink-0" aria-hidden="true" /> - <h2 className="font-sans text-base font-semibold text-foreground">Rewards</h2> - </div> - - {!compact && ( - deadlinePast ? ( - <div className="text-xs text-dim leading-relaxed mb-4 space-y-2"> - <p>The deadline has passed, but the adventure is still open. Play it, post your solution in the community.</p> - <p> - If you enjoyed the adventure and want to share what you learned, write a tutorial in{" "} - <a - href={`${COMMUNITY_URL}/c/community-voices/38`} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - className="docs-ext-link" - > - Community Voices - <ExternalLink size={10} aria-hidden="true" /> - </a> - . - </p> - </div> - ) : ( - <p className="text-xs text-dim leading-relaxed mb-4"> - <span className="md-inline" dangerouslySetInnerHTML={{ __html: rewards.eligibility }} /> - </p> - ) - )} - - {compact && deadlinePast && ( - <p className="text-xs text-dim leading-relaxed mb-3"> - Deadline passed. Adventure still open. - </p> - )} - - <div className={`space-y-2${compact ? "" : " mb-4"}`}> - {rewards.tiers.map((tier) => ( - <div key={tier.label}> - <p className="text-xs font-semibold text-foreground">{tier.label}</p> - <InlineProse html={tier.description} className="text-xs text-dim" /> - </div> - ))} - </div> - - {!compact && rewards.rankingNote && ( - <p className="text-xs text-faint leading-relaxed mt-4"> - <span className="md-inline" dangerouslySetInnerHTML={{ __html: rewards.rankingNote }} />{" "} - {rewards.rankingRulesUrl && ( - <a - href={rewards.rankingRulesUrl} - target="_blank" - rel="noopener noreferrer" aria-describedby="new-tab-hint" - className="docs-ext-link" - > - See the points & ranking rules for the full breakdown - <ExternalLink size={10} aria-hidden="true" /> - </a> - )} - </p> - )} - - {(compact ? levelDeadline : rewards.deadline) && ( - <> - <div className="border-t border-border my-3" /> - <p className="text-xs text-faint"> - Deadline:{" "} - <span className="font-medium text-foreground"> - {compact ? (levelDeadline ? formatDeadline(levelDeadline) : null) : formatDeadline(rewards.deadline)} - </span> - </p> - </> - )} - </div> -); diff --git a/src/components/SEO.astro b/src/components/SEO.astro new file mode 100644 index 000000000..68dcd6ca9 --- /dev/null +++ b/src/components/SEO.astro @@ -0,0 +1,40 @@ +--- +import { SITE_URL, BRAND_NAME, OG_IMAGE_ALT, canonicalUrl } from "@/lib/site"; + +// Per-page SEO tags. Rendered inside +// <head> by Layout.astro. og:image is a fixed brand card (public/og.png). +interface Props { + title: string; + description: string; + /** Canonical path, e.g. "/adventures/echoes-lost-in-orbit/". */ + path: string; + ogType?: string; + /** Emit <meta name="robots" content="noindex"> (e.g. legal pages kept out of the index). */ + noindex?: boolean; +} +const { title, description, path, ogType = "website", noindex = false } = Astro.props; +const canonical = canonicalUrl(path); +const ogImage = `${SITE_URL}/og.png`; +--- + +<title>{title} + +{noindex && } + + + + + + + + + + + + + + + + + + diff --git a/src/components/ScenarioSection.tsx b/src/components/ScenarioSection.tsx deleted file mode 100644 index 9f2c80da6..000000000 --- a/src/components/ScenarioSection.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { JSX } from "react"; -import { CollapsibleSection } from "@/components/CollapsibleSection"; -import { InlineProse } from "@/components/InlineProse"; - -type ScenarioSectionProps = { - backstory: string[]; -}; - -export const ScenarioSection = ({ backstory }: ScenarioSectionProps): JSX.Element => ( - -
- {backstory.map((para, i) => ( - - ))} -
-
-); diff --git a/src/components/SectionLabel.astro b/src/components/SectionLabel.astro new file mode 100644 index 000000000..5d561a0cb --- /dev/null +++ b/src/components/SectionLabel.astro @@ -0,0 +1,11 @@ +--- +// Uppercased eyebrow label rendered above section h2s. The CSS class +// `section-label` applies text-transform: uppercase, so source text stays +// lowercase per styleguide.md. +--- + +
+ +
diff --git a/src/components/SectionLabel.tsx b/src/components/SectionLabel.tsx deleted file mode 100644 index bdb2208d9..000000000 --- a/src/components/SectionLabel.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { JSX, ReactNode } from "react"; - -type SectionLabelProps = { - children: ReactNode; -}; - -// Uppercased eyebrow label rendered above section h2s. The CSS class -// `section-label` applies `text-transform: uppercase`, so source text stays -// lowercase per styleguide.md. -export const SectionLabel = ({ children }: SectionLabelProps): JSX.Element => ( -
- - {children} - -
-); diff --git a/src/components/SidebarLayout.tsx b/src/components/SidebarLayout.tsx deleted file mode 100644 index c0787b51f..000000000 --- a/src/components/SidebarLayout.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { type ReactNode, type JSX } from "react"; - -type SidebarLayoutProps = { - children: ReactNode; - aside?: ReactNode; -}; - -/** - * Renders children in a two-column grid when `aside` is provided (lg+), - * with the sidebar column sticky at the top. Falls back to rendering - * children alone when `aside` is omitted. - * - * Used by CommunitySection, ChallengeBuildersSection, and CommunityGuide - * so the sticky-sidebar grid is defined in one place. - */ -export const SidebarLayout = ({ children, aside }: SidebarLayoutProps): JSX.Element => { - if (!aside) return <>{children}; - return ( -
- {children} -
-
{aside}
-
-
- ); -}; diff --git a/src/components/SolutionBlocks.astro b/src/components/SolutionBlocks.astro new file mode 100644 index 000000000..4924dea56 --- /dev/null +++ b/src/components/SolutionBlocks.astro @@ -0,0 +1,113 @@ +--- +import IconCopy from "~icons/lucide/copy"; +import IconLightbulb from "~icons/lucide/lightbulb"; +import IconTriangleAlert from "~icons/lucide/triangle-alert"; +import IconInfo from "~icons/lucide/info"; +import type { SolutionBlock } from "@/lib/solutions"; + +// Renders a SolutionBlock[] body (text, code, image, callout). Ported from the +// BlockRenderer / Callout / SolutionImage sub-components in src/pages/SolutionDetail.tsx. +interface Props { + blocks: SolutionBlock[]; +} +const { blocks } = Astro.props; +const base = import.meta.env.BASE_URL; + +const calloutConfig = { + tip: { + label: "Tip", + class: "border-primary/30 bg-primary/5", + iconClass: "text-primary", + }, + warning: { + label: "Warning", + class: "border-orange-500/30 bg-orange-500/5", + iconClass: "text-orange-400", + }, + info: { + label: "Info", + class: "border-blue-400/30 bg-blue-400/5", + iconClass: "text-blue-400", + }, +} as const; + +const calloutIconMap = { + tip: IconLightbulb, + warning: IconTriangleAlert, + info: IconInfo, +} as const; + +// Solution image srcs are stored root-absolute (/solutions/...); prefix the base +// so they resolve in PR previews too. +const resolveSrc = (src: string): string => (src.startsWith("/") ? base + src.slice(1) : src); +--- + +
+ { + blocks.map((block) => { + if (block.type === "text") { + return
; + } + if (block.type === "code") { + // Same build-time header + Copy-button structure as prose code blocks + // (rendered by markdown-pipeline.mjs); Layout.astro wires the click only. + const label = block.title ?? block.language ?? "code"; + return ( +
+
+ + +
+
+
{block.code}
+
+
+ ); + } + if (block.type === "image") { + return ( +
+ {block.alt} + {block.caption && ( +
{block.caption}
+ )} +
+ ); + } + const config = calloutConfig[block.variant as keyof typeof calloutConfig]; + const CalloutIcon = calloutIconMap[block.variant as keyof typeof calloutIconMap]; + if (!config || !CalloutIcon) return null; + return ( +
+
diff --git a/src/components/SolutionStepNav.astro b/src/components/SolutionStepNav.astro new file mode 100644 index 000000000..2a67fb161 --- /dev/null +++ b/src/components/SolutionStepNav.astro @@ -0,0 +1,39 @@ +--- +// "What was fixed" step jump-nav. Ported from the StepNav sub-component in +// src/pages/SolutionDetail.tsx. Rendered twice on the solution page: in the +// sidebar (desktop) and inline above the article (mobile). +interface Props { + steps: { id: string; title: string }[]; + class?: string; +} +const { steps, class: className = "" } = Astro.props; +--- + + diff --git a/src/components/SponsorStrip.astro b/src/components/SponsorStrip.astro new file mode 100644 index 000000000..de9c40187 --- /dev/null +++ b/src/components/SponsorStrip.astro @@ -0,0 +1,26 @@ +--- +import IconArrowRight from "~icons/lucide/arrow-right"; +import { BRAND_NAME } from "@/lib/site"; + +const base = import.meta.env.BASE_URL; +--- + +
+
+
+ +
+
+

+ Sponsor challenges, swag, or licenses and connect with the next generation of open source contributors. +

+ +
+
+
diff --git a/src/components/SponsorStrip.tsx b/src/components/SponsorStrip.tsx deleted file mode 100644 index ed13dd299..000000000 --- a/src/components/SponsorStrip.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { type JSX } from "react"; -import { Link } from "react-router"; -import { ArrowRight } from "lucide-react"; -import { BRAND_NAME } from "@/data/constants"; - -export const SponsorStrip = (): JSX.Element => { - return ( -
-
-
- -
-
-

- Sponsor challenges, swag, or licenses and connect with the next generation of open source contributors. -

-
- - Become a Sponsor
-
-
-
- ); -}; diff --git a/src/components/StarterNudge.astro b/src/components/StarterNudge.astro new file mode 100644 index 000000000..7a7dec815 --- /dev/null +++ b/src/components/StarterNudge.astro @@ -0,0 +1,84 @@ +--- +import IconX from "~icons/lucide/x"; + +// Dismissable "new here?" pointer at the starter challenge. +// +// Rendered `hidden` and revealed by script only when it has not been dismissed, +// so a returning visitor who dismissed it never sees it flash. The starter +// adventure and level are resolved at build time by the caller. + +interface Props { + adventureId: string; + adventureTitle: string; + tag: string; + levelId: string; + base: string; +} +const { adventureId, adventureTitle, tag, levelId, base } = Astro.props; +const href = `${base}adventures/${adventureId}/levels/${levelId}/`; +--- + +{/* aria-live so the nudge is announced when the script reveals it, rather than + appearing silently after the page has settled. */} +
+ +
+ + diff --git a/src/components/StarterNudge.tsx b/src/components/StarterNudge.tsx deleted file mode 100644 index a1063f039..000000000 --- a/src/components/StarterNudge.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useState, useEffect, type JSX } from "react"; -import { Link } from "react-router"; -import { X } from "lucide-react"; -import { ADVENTURE_SUMMARIES } from "@/data/adventures/summaries"; - -const STARTER_NUDGE_KEY = "starter_nudge_dismissed"; -const starterAdventure = ADVENTURE_SUMMARIES.find((a) => a.isLive); -const starterLevel = starterAdventure?.levels.find((l) => l.difficulty === "Beginner"); - -export const StarterNudge = (): JSX.Element | null => { - const [showNudge, setShowNudge] = useState(false); - - useEffect(() => { - let dismissed: boolean; - try { - dismissed = !!localStorage.getItem(STARTER_NUDGE_KEY); - } catch { - // localStorage unavailable; skip nudge - return; - } - if (starterLevel && !dismissed) { - const id = setTimeout(() => setShowNudge(true)); - return () => clearTimeout(id); - } - }, []); - - const dismissNudge = (): void => { - try { - localStorage.setItem(STARTER_NUDGE_KEY, "1"); - } catch { - // localStorage unavailable; nudge will reappear next visit - } - setShowNudge(false); - }; - - if (!showNudge || !starterAdventure || !starterLevel) return null; - - return ( -
-

- Each adventure focuses on one open source technology, with challenges at different difficulty levels.{" "} - - New here?{" "} - - Start with {starterAdventure.title}, a {starterAdventure.tags[0]} adventure - - -

- -
- ); -}; diff --git a/src/components/StructuredData.astro b/src/components/StructuredData.astro new file mode 100644 index 000000000..b6f7ef569 --- /dev/null +++ b/src/components/StructuredData.astro @@ -0,0 +1,55 @@ +--- +import { canonicalUrl } from "@/lib/site"; + +// Page-level schema.org JSON-LD, rendered into via Layout's "head" slot. +// +// The BreadcrumbList is derived from the SAME crumb array that renders the +// visual , so a change to one always changes the other. Google's +// BreadcrumbList is expected to start at Home, which the visual trail omits +// (the navbar logo is the home affordance), so Home is prepended here only. +// +// Every `item` is an absolute production URL (canonicalUrl), never base-prefixed: +// structured data must point at the canonical site even from a PR preview, the +// same rule follows. + +export interface Crumb { + label: string; + href?: string; +} + +interface Props { + /** The exact array passed to on this page. */ + breadcrumb: Crumb[]; + /** Canonical path of this page. Supplies the item URL for the final crumb. */ + path: string; + /** Additional schema.org objects (Course, LearningResource, ...) without @context. */ + schemas?: Record[]; +} + +const { breadcrumb, path, schemas = [] } = Astro.props; + +const trail: Crumb[] = [{ label: "Home", href: "/" }, ...breadcrumb]; + +const breadcrumbJsonLd = JSON.stringify({ + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: trail.map((crumb, i) => ({ + "@type": "ListItem", + position: i + 1, + name: crumb.label, + // The last crumb is the current page and carries no href by design. + item: canonicalUrl(crumb.href ?? path), + })), +}); +--- + + diff --git a/src/components/WalkthroughSection.tsx b/src/components/WalkthroughSection.tsx deleted file mode 100644 index 487dfb676..000000000 --- a/src/components/WalkthroughSection.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useState, useRef, useEffect } from "react"; -import type { JSX } from "react"; -import { ChevronDown } from "lucide-react"; -import { CollapsibleSection } from "@/components/CollapsibleSection"; -import { MarkdownContent } from "@/components/MarkdownContent"; -import { stripLinks } from "@/lib/markdown"; -import { useIsomorphicLayoutEffect } from "@/hooks/useIsomorphicLayoutEffect"; -import type { WalkthroughStep } from "@/data/adventures"; - -type WalkthroughSectionProps = { - steps: WalkthroughStep[]; -}; - -export const WalkthroughSection = ({ steps }: WalkthroughSectionProps): JSX.Element => { - const [openSteps, setOpenSteps] = useState(() => steps.map(() => true)); - const listRef = useRef(null); - - const toggle = (i: number): void => - setOpenSteps((prev) => prev.map((open, idx) => (idx === i ? !open : open))); - - // Manage hidden="until-found" synchronously before paint so there is no flash - // between the React commit and the attribute being applied. - useIsomorphicLayoutEffect(() => { - const list = listRef.current; - if (!list) return; - openSteps.forEach((isOpen, i) => { - const panel = list.querySelector(`#walkthrough-step-${i}`); - if (!panel) return; - if (isOpen) { - panel.removeAttribute("hidden"); - } else { - panel.setAttribute("hidden", "until-found"); - } - }); - }, [openSteps]); - - // beforematch fires on the element with hidden="until-found" when the browser - // auto-reveals it via find-in-page or fragment navigation. It does not bubble, - // so listeners are attached individually to each panel on mount. - useEffect(() => { - const list = listRef.current; - if (!list) return; - const cleanups = steps.map((_, i) => { - const panel = list.querySelector(`#walkthrough-step-${i}`); - if (!panel) return (): void => {}; - const onBeforeMatch = (): void => { - setOpenSteps((prev) => prev.map((open, idx) => (idx === i ? true : open))); - }; - panel.addEventListener("beforematch", onBeforeMatch); - return (): void => panel.removeEventListener("beforematch", onBeforeMatch); - }); - return () => cleanups.forEach((fn) => fn()); - }, [steps]); - - return ( - -
    - {steps.map((step, i) => { - const isOpen = openSteps[i] ?? true; - const contentId = `walkthrough-step-${i}`; - return ( -
  1. - -
    - -
  2. - ); - })} -
-
- ); -}; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx deleted file mode 100644 index 0853c441d..000000000 --- a/src/components/ui/badge.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import * as React from "react"; -import { cva, type VariantProps } from "class-variance-authority"; - -import { cn } from "@/lib/utils"; - -const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", - { - variants: { - variant: { - default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", - secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", - destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", - outline: "text-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - }, -); - -export interface BadgeProps extends React.HTMLAttributes, VariantProps {} - -function Badge({ className, variant, ...props }: BadgeProps) { - return
; -} - -export { Badge, badgeVariants }; diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx deleted file mode 100644 index 2dece61d0..000000000 --- a/src/components/ui/tooltip.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import * as React from "react"; -import * as TooltipPrimitive from "@radix-ui/react-tooltip"; - -import { cn } from "@/lib/utils"; - -const TooltipProvider = TooltipPrimitive.Provider; - -const Tooltip = TooltipPrimitive.Root; - -const TooltipTrigger = TooltipPrimitive.Trigger; - -const TooltipContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - -)); -TooltipContent.displayName = TooltipPrimitive.Content.displayName; - -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 000000000..4dcecacf8 --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,401 @@ +import { readdirSync, existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { defineCollection } from "astro:content"; +import type { Loader } from "astro/loaders"; +import { z } from "astro/zod"; +import { parse as parseYaml } from "yaml"; +import { + beginAbbrScope, + mdToInline, + mdToBlock, + mdToInlineArray, + mdToBlockArray, +} from "./lib/markdown-pipeline.mjs"; +import { LEVEL_DIFFICULTY_BY_EMOJI } from "./lib/level-constants.mjs"; +import { parseDeadline } from "./lib/deadline.mjs"; +import { + buildLevelMetaDescription, + buildAdventureMetaDescription, + buildServicesStepBody, +} from "./lib/adventure-derive.mjs"; +import { COMMUNITY_URL } from "./lib/site"; +import { EMOJI_TO_ICON } from "./lib/adventure-icons"; +import type { AdventureRewards } from "./data/adventures/types"; + +// Adventure YAML lives in this app's own data dir (src/data/adventures), +// resolved from this file's location (src/content.config.ts). +const ADVENTURES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "data/adventures"); + +const CODESPACES_BASE = "https://codespaces.new/off-on-dev/open-source-challenges"; + + +const DEFAULT_REWARDS_ELIGIBILITY = + "Complete all levels and post your solution in the community before the deadline to be eligible."; +const DEFAULT_REWARDS_RANKING_NOTE = + "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)."; +const DEFAULT_REWARDS_RANKING_RULES_PATH = "/t/about-the-challenges-category/16"; + +const DIFFICULTY = z.enum(["Beginner", "Intermediate", "Expert"]); + +// --- Zod schema (translated from schemas/adventure.schema.json) --- +// .strict() mirrors additionalProperties:false and preserves the ajv validation +// gate: unknown fields fail the build (via `astro sync` / `astro build`). + +const contributorSchema = z + .object({ name: z.string(), url: z.url().optional(), about: z.string().optional() }) + .strict(); + +const rewardsSchema = z + .object({ + deadline: z.string(), + eligibility: z.string().optional(), + tiers: z.array(z.object({ label: z.string(), description: z.string() }).strict()), + ranking_note: z.string().optional(), + ranking_rules_url: z.string().optional(), + }) + .strict(); + +const upcomingLevelSchema = z + .object({ level: z.string().optional(), name: z.string(), difficulty: DIFFICULTY }) + .strict(); + +const toolboxItemSchema = z + .object({ name: z.string(), description: z.string(), url: z.url().optional() }) + .strict(); + +const serviceSchema = z + .object({ + name: z.string(), + port: z.union([z.string(), z.number()]).optional(), + credentials: z.string().optional(), + description: z.string(), + internal: z.boolean().optional(), + }) + .strict(); + +const howToPlayStepSchema = z + .object({ id: z.string().optional(), title: z.string(), content: z.string() }) + .strict(); + +const verificationSchema = z.object({ command: z.string(), description: z.string() }).strict(); + +const helpfulLinkSchema = z + .object({ title: z.string(), url: z.url(), description: z.string().optional() }) + .strict(); + +const levelSchema = z + .object({ + level: z.string(), + name: z.string().optional(), + title: z.string().optional(), + emoji: z.string().optional(), + difficulty: DIFFICULTY.optional(), + topics: z.array(z.string()), + learnings: z.array(z.string()).min(1).optional(), + what_you_learn: z.array(z.string()).min(1).optional(), + devcontainer: z.string(), + codespaces_machine: z.enum(["4core"]).optional(), + discussion_url: z.string().optional(), + community_url: z.string().optional(), + deadline: z.string().optional(), + hook: z.string().optional(), + summary: z.string().optional(), + intro: z.array(z.string()).optional(), + backstory: z.array(z.string()).optional(), + objective: z.array(z.string()), + audience: z.string().optional(), + estimated_time: z.string().optional(), + scenario: z.string().optional(), + architecture: z.array(z.string()).optional(), + architecture_diagram: z.string().optional(), + diagram_alt: z.string().optional(), + architecture_ascii: z.string().optional(), + toolbox: z.array(toolboxItemSchema), + services: z.array(serviceSchema).optional(), + how_to_play: z.array(howToPlayStepSchema), + verification: verificationSchema, + helpful_links: z.array(helpfulLinkSchema).optional(), + meta_description: z.string().max(160).optional(), + solved_count: z.number().int().optional(), + top_players: z + .array(z.object({ username: z.string(), count: z.number().int() }).strict()) + .optional(), + }) + .strict() + .refine((l) => l.name || l.title, { message: "level needs name or title" }) + .refine((l) => l.difficulty || (l.emoji && LEVEL_DIFFICULTY_BY_EMOJI[l.emoji as keyof typeof LEVEL_DIFFICULTY_BY_EMOJI]), { + message: "level needs difficulty or a 🟢/🟡/🔴 emoji", + }) + .refine((l) => l.learnings || l.what_you_learn, { + message: "level needs learnings or what_you_learn", + }); + +// --- Resolvers --- + +function requireEither(a: string | undefined | null, b: string | undefined | null, field: string): string { + const value = a ?? b; + if (value != null && value !== "") return value; + throw new Error(`Content validation error: ${field} is required but was not provided`); +} + +function resolveCodespacesUrl(devcontainer: string, machine?: string): string { + const path = `.devcontainer/${devcontainer}/devcontainer.json`; + const encoded = encodeURIComponent(path); + const machineParam = machine === "4core" ? "&machine=standardLinux32gb" : ""; + return `${CODESPACES_BASE}?devcontainer_path=${encoded}&quickstart=1${machineParam}`; +} + +function resolveDiscussionUrl(raw?: string): string { + const value = raw ?? ""; + if (!value) return ""; + if (value.startsWith("http")) return value; + const path = value.startsWith("/") ? value : `/${value}`; + return `${COMMUNITY_URL}${path}`; +} + +function resolveCommunityPath(url: string): string { + if (url.startsWith("http")) return url; + const path = url.startsWith("/") ? url : `/${url}`; + return `${COMMUNITY_URL}${path}`; +} + +type RenderedLevel = { + id: string; + name: string; + difficulty: "Beginner" | "Intermediate" | "Expert"; + topics: string[]; + learnings: string[]; + codespacesUrl: string; + discussionUrl: string; + deadline?: string | null; + hook?: string; + intro?: string[]; + backstory?: string[]; + objective: string[]; + audience?: string; + estimatedTime?: string; + scenario?: string; + architecture?: string[]; + architectureDiagram?: string; + diagramAlt?: string; + architectureAscii?: string; + toolbox: { name: string; description: string; url?: string }[]; + howToPlay: { title: string; content: string }[]; + helpfulLinks?: { title: string; url: string; description?: string }[]; + verification: { command: string; description: string }; + metaDescription: string; +} + +async function renderLevel(level: z.infer): Promise { + const difficulty = level.difficulty ?? (level.emoji ? LEVEL_DIFFICULTY_BY_EMOJI[level.emoji as keyof typeof LEVEL_DIFFICULTY_BY_EMOJI] : undefined); + const learnings = level.learnings ?? level.what_you_learn ?? []; + const intro = level.intro ?? (level.summary ? [level.summary] : undefined); + + // Inject an "Explore the UIs" step from services at index 1. + const steps: { title: string; content: string }[] = [...level.how_to_play]; + const servicesBody = buildServicesStepBody(level.services); + if (servicesBody) steps.splice(1, 0, { title: "Explore the UIs", content: servicesBody }); + + const [ + learningsHtml, + audienceHtml, + objectiveHtml, + introHtml, + backstoryHtml, + scenarioHtml, + architectureHtml, + toolbox, + howToPlay, + ] = await Promise.all([ + mdToInlineArray(learnings), + level.audience ? mdToInline(level.audience) : Promise.resolve(null), + mdToInlineArray(level.objective), + intro ? mdToInlineArray(intro) : Promise.resolve(null), + level.backstory ? mdToInlineArray(level.backstory) : Promise.resolve(null), + level.scenario ? mdToBlock(level.scenario) : Promise.resolve(null), + level.architecture ? mdToBlockArray(level.architecture) : Promise.resolve(null), + Promise.all( + level.toolbox.map(async (t) => ({ ...t, description: await mdToInline(t.description) })), + ), + Promise.all( + steps.map(async (s) => ({ + title: await mdToInline(s.title), + content: await mdToBlock(s.content), + })), + ), + ]); + + return { + id: level.level, + name: requireEither(level.name, level.title, "level name/title"), + // The schema's refine() guarantees difficulty resolves from either the + // explicit field or the emoji, so it cannot be undefined here. + difficulty: difficulty as RenderedLevel["difficulty"], + topics: level.topics, + learnings: learningsHtml, + codespacesUrl: resolveCodespacesUrl(level.devcontainer, level.codespaces_machine), + discussionUrl: resolveDiscussionUrl(level.discussion_url ?? level.community_url), + ...(level.deadline ? { deadline: parseDeadline(level.deadline) } : {}), + ...(level.hook ? { hook: level.hook } : {}), + ...(introHtml ? { intro: introHtml } : {}), + ...(backstoryHtml ? { backstory: backstoryHtml } : {}), + objective: objectiveHtml, + ...(audienceHtml ? { audience: audienceHtml } : {}), + ...(level.estimated_time ? { estimatedTime: level.estimated_time } : {}), + ...(scenarioHtml ? { scenario: scenarioHtml } : {}), + ...(architectureHtml ? { architecture: architectureHtml } : {}), + // architectureDiagram is a filename; the level page resolves it against the + // src/assets/diagrams glob (import.meta.glob) to a hashed, emitted asset URL. + ...(level.architecture_diagram ? { architectureDiagram: level.architecture_diagram } : {}), + ...(level.diagram_alt ? { diagramAlt: level.diagram_alt } : {}), + ...(level.architecture_ascii ? { architectureAscii: level.architecture_ascii } : {}), + toolbox, + howToPlay, + ...(level.helpful_links ? { helpfulLinks: level.helpful_links } : {}), + verification: level.verification, + metaDescription: level.meta_description || buildLevelMetaDescription(level), + }; +} + +async function renderRewards( + rewards: z.infer, +): Promise { + const eligibility = await mdToInline(rewards.eligibility ?? DEFAULT_REWARDS_ELIGIBILITY); + const rankingNote = await mdToInline(rewards.ranking_note ?? DEFAULT_REWARDS_RANKING_NOTE); + const tiers = await Promise.all( + rewards.tiers.map(async (t) => ({ label: t.label, description: await mdToInline(t.description) })), + ); + return { + deadline: rewards.deadline === "TODO" ? "" : (parseDeadline(rewards.deadline) ?? ""), + eligibility, + tiers, + rankingNote, + rankingRulesUrl: resolveCommunityPath( + rewards.ranking_rules_url ?? DEFAULT_REWARDS_RANKING_RULES_PATH, + ), + }; +} + +// Custom loader: parses YAML with the `yaml` package (YAML 1.2 core), matching +// the generator. Astro's built-in glob() YAML parser auto-casts unquoted ISO +// timestamps to Date objects, corrupting deadline fields; this avoids that and +// gives digest-gated incremental rendering. +function adventuresLoader(): Loader { + return { + name: "adventures-loader", + async load({ store, parseData, generateDigest, watcher }) { + const seen = new Set(); + let entries; + try { + entries = readdirSync(ADVENTURES_DIR, { withFileTypes: true }); + } catch (err) { + throw new Error( + `[adventures-loader] Cannot read adventures directory "${ADVENTURES_DIR}" — build aborted to prevent deploying a site with no adventure pages.`, + { cause: err }, + ); + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const yamlPath = resolve(ADVENTURES_DIR, entry.name, "adventure.yaml"); + if (!existsSync(yamlPath)) continue; + seen.add(entry.name); + const raw = readFileSync(yamlPath, "utf8"); + const digest = generateDigest(raw); + // Digest is over the YAML only, so a change to the RENDERING code + // (markdown-pipeline.mjs / adventure-derive.mjs) does not invalidate a + // persisted store. CI is unaffected (fresh npm ci → empty store → full + // render); locally, clear node_modules/.astro/data-store.json (or .astro) + // after editing the pipeline to force a re-render. + watcher?.add(yamlPath); + if (store.get(entry.name)?.digest === digest) continue; // unchanged: skip re-render + // Scope this entry's abbreviation IDs so they cannot collide with + // another adventure's on pages that render several (home, /challenges/). + beginAbbrScope(entry.name); + const data = await parseData({ id: entry.name, data: parseYaml(raw) }); + if (data.slug !== entry.name) { + throw new Error( + `Adventure "${entry.name}": YAML slug "${data.slug}" must match the directory name. Rename one or the other.`, + ); + } + store.set({ id: entry.name, data, digest }); + } + // Drop entries whose YAML was deleted. + for (const id of [...store.keys()]) if (!seen.has(id)) store.delete(id); + }, + }; +} + +const adventures = defineCollection({ + loader: adventuresLoader(), + schema: z + .object({ + slug: z.string().regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/), + title: z.string().optional(), + name: z.string().optional(), + emoji: z.string().optional(), + icon: z.string().optional(), + month: z.string().regex(/^[A-Z]{3} \d{4}$/), + story: z.string().optional(), + tags: z.array(z.string()).min(1), + contributor: contributorSchema.optional(), + community_category_id: z.number().int().optional(), + meta_description: z.string().max(160).optional(), + backstory: z.array(z.string()).optional(), + overview: z.array(z.string()).optional(), + rewards: rewardsSchema.optional(), + upcoming_levels: z.array(upcomingLevelSchema).optional(), + levels: z.array(levelSchema).min(1), + }) + .strict() + .refine((d) => d.title || d.name, { message: "adventure needs title or name" }) + .transform(async (data) => { + const title = requireEither(data.title, data.name, "adventure title/name"); + const story = + data.story ?? (data.backstory && data.backstory.length > 0 ? data.backstory[0] : ""); + const icon = data.icon ?? (data.emoji ? EMOJI_TO_ICON[data.emoji as keyof typeof EMOJI_TO_ICON] : undefined); + + const [storyHtml, aboutHtml, backstoryHtml, levels, rewards] = await Promise.all([ + mdToInline(story), + data.contributor?.about ? mdToInline(data.contributor.about) : Promise.resolve(null), + data.backstory ? mdToInlineArray(data.backstory) : Promise.resolve(null), + Promise.all(data.levels.map(renderLevel)), + data.rewards ? renderRewards(data.rewards) : Promise.resolve(null), + ]); + + return { + slug: data.slug, + title, + month: data.month, + story: storyHtml, + metaDescription: data.meta_description || buildAdventureMetaDescription(data), + tags: data.tags, + ...(icon ? { icon } : {}), + ...(data.contributor + ? { + contributor: { + name: data.contributor.name, + url: data.contributor.url, + aboutHtml: aboutHtml ?? undefined, + }, + } + : {}), + ...(backstoryHtml ? { backstory: backstoryHtml } : {}), + ...(data.overview ? { overview: data.overview } : {}), + ...(rewards ? { rewards } : {}), + ...(data.upcoming_levels + ? { + upcomingLevels: data.upcoming_levels.map((u) => ({ + name: u.name, + difficulty: u.difficulty, + })), + } + : {}), + levels, + }; + }), +}); + +export const collections = { adventures }; diff --git a/src/data/adventures/blind-by-design.generated.ts b/src/data/adventures/blind-by-design.generated.ts deleted file mode 100644 index e80b4f802..000000000 --- a/src/data/adventures/blind-by-design.generated.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { CODESPACES_BASE, COMMUNITY_URL } from "@/data/constants"; -import blindByDesignIntermediate from "@/assets/diagrams/blind-by-design-intermediate.svg"; -import blindByDesignExpert from "@/assets/diagrams/blind-by-design-expert.svg"; -import type { Adventure } from "./types"; - -export const BLIND_BY_DESIGN: Adventure = { - id: "blind-by-design", - title: "Blind by Design", - icon: "FlaskConical", - month: "MAY 2026", - 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 (Intermediate), then instrument flag evaluations with OpenTelemetry and roll back a misbehaving fractional rollout (Expert). All without redeploying.", - metaDescription: "OpenFeature is a vendor-neutral standard for feature flags. The reference cloud-native implementation is flagd, which serves flag definitions from a JSON...", - tags: ["OpenFeature", "flagd", "Spring Boot", "Java", "OpenTelemetry", "Grafana"], - contributor: { - name: "Simon Schrottner", - url: "https://schrottner.at/", - aboutHtml: "CNCFCloud Native Computing Foundation Ambassador and maintainer of OpenFeature and JUnit Pioneer. Helps teams release faster and with more confidence through open standards, feature flagging, and the communities that make both possible. A familiar face at KubeCon EU, Devoxx, ContainerDays, and meetups across Europe.", - }, - backstory: [ - "The Aletheia Institute is running a multi-phase vision-enhancement trial. The lab is a Spring Boot service whose one job is to record the vision_state of every subject who walks through the protocol (blurry, sharp, enhanced, or clouded), because subjects don't all arrive with the same biology, the same dose adherence, or the same trial-jurisdiction baseline. The flag definitions that drive those readings live in flags.json, watched by a flagd sidecar; the OpenFeature SDKSoftware Development Kit is supposed to call that sidecar on every evaluation.", - "It hasn't been. For the past eight months, every subject through the door has been recorded as \"untreated\": the integration was never finished, and the lab director assumed the system was reading the chart. Worse, eight weeks ago the Institute opened its flagship Phase 3 trial: a new amplifier variant rolled out fractionally to a cohort by a targeting rule in flags.json. Four adverse-event reports have since been filed, each one a subject whose vision_state at discharge was worse than at enrollment.", - "The monitoring is dark, not by accident, but because no one ever turned the lights on. Your mission across three levels: stand up the lab so it reads the chart, read the chart by cohort so outcomes can be tracked, then turn on the lights and roll back the Phase 3 variant before the director signs off on the next enrollment batch.", - ], - overview: [ - "OpenFeature is a vendor-neutral standard for feature flags. The reference cloud-native implementation is flagd, which serves flag definitions from a JSON file, locally or remotely, and the OpenFeature SDK in your application calls it on every evaluation.", - "In this adventure, the lab uses OpenFeature exactly the way a real engineering team would: a Spring Boot service holds the SDK client, flagd holds the flag definitions, and the targeting rules in flags.json decide what reading every subject ends up with. By the end, you'll have wired the SDK in from scratch, learned to record outcomes by cohort, and rolled back a misbehaving Phase 3 trial without redeploying.", - ], - rewards: { - deadline: "2026-05-26T23:59:00+01:00", - 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: "Stand up the Lab", - difficulty: "Beginner", - topics: ["OpenFeature", "flagd", "Spring Boot"], - audience: "Platform engineers, SREsSite Reliability Engineers, and developers curious about feature flags, with no prior OpenFeature experience needed, but familiarity with Spring Boot and basic Java will help.", - learnings: [ - "How an OpenFeature client and provider work together: the SDKSoftware Development Kit is provider-agnostic and the flagd provider plugs in via dependency only", - "What remote provider means in practice: the SDK calls a separate flag service (flagd) over gRPCGoogle Remote Procedure Call, 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", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F04-blind-by-design_01-beginner%2Fdevcontainer.json&quickstart=1`, - discussionUrl: `${COMMUNITY_URL}/t/wire-openfeature-flagd-into-a-spring-boot-service-with-zero-setup-adventure-04-beginner/1419`, - deadline: "2026-05-26T23:59:00+01:00", - intro: [ - "Wire the OpenFeature Java SDK and the flagd contrib provider into a Spring Boot service so flag evaluations are resolved by a flagd sidecar against a flags.json file. Author your first flag, then prove that editing flags.json flips the response on the next request: no app restart, no flagd restart, no redeploy.", - ], - backstory: [ - "The lab is on its first shift and it isn't reading the chart. Every subject who walks through the door gets the same hard-coded reading on their record, no matter what the lab director just signed off on. The label coming out of the lab is a literal string baked into the controller, not a reading pulled from the chart.", - "Your mission: replace that hard-coded label with an OpenFeature client, point that client at the flagd sidecar that already runs next to your Codespace, and let flags.json drive what gets recorded as the subject's vision_state. Prove the lab can change what it records without restarting anything.", - ], - objective: [ - "curl http://localhost:8080/ returns a vision_state reading resolved from flags.json (not the hard-coded 'untreated' fallback)", - "The response payload includes OpenFeature evaluation details: flag key, variant, reason, and value", - "Editing flags.json to change defaultVariant causes the next request to return the new variant without restarting the app or flagd", - ], - architecture: [ - "

This level runs as two containers side-by-side in your Codespace: the Spring Boot lab and a flagd sidecar.

", - "

The Spring Boot service runs on http://localhost:8080/ with one endpoint, GET /. flags.json is mounted read-only into the flagd sidecar; edit it through the IDEIntegrated Development Environment and flagd's file watcher picks up the change within about a second. The flagd sidecar serves flag evaluations over gRPC on :8013. The OpenFeature SDK reads FLAGD_HOST and FLAGD_PORT from the environment (pre-set by the devcontainer), so there is no host or port to hard-code.

", - ], - toolbox: [ - { name: "./mvnw", description: "Maven wrapper checked in next to pom.xml, builds and runs the Spring Boot lab" }, - { name: "curl", description: "makes requests to http://localhost:8080/ and shows the reading the lab records", url: "https://curl.se/" }, - { name: "jq", description: "pretty-prints and filters the JSON evaluation details returned by the SDK", url: "https://jqlang.org/" }, - { name: "flagd sidecar", description: "already running in the devcontainer compose stack on the docker-internal network, no port forwarding needed" }, - ], - howToPlay: [ - { title: "Start the Lab", content: `

Run the lab from the terminal, or press F5 in VS Code with Laboratory.java open. The lab starts in the broken state, returning the hard-coded 'untreated' response:

-
./mvnw spring-boot:run
-
` }, - { title: "Explore the UIs", content: `

Open the Ports tab and navigate to each service:

-
    -
  • Port 8080: Spring Boot lab. The lab endpoint. On first load you will see the hard-coded 'untreated' response. This is the broken state you are fixing.
  • -
` }, - { title: "Add Dependencies", content: "

Add the OpenFeature Java SDK and flagd contrib provider to pom.xml. GroupIds, artifactIds, and versions are in the OpenFeature Java SDK docs and the flagd Java provider README.

" }, - { title: "Configure the Provider", content: "

Create a Spring @Configuration class that builds a FlagdProvider in RPC mode and registers it on the OpenFeature API at startup. No host or port to configure: the devcontainer pre-sets FLAGD_HOST and FLAGD_PORT.

" }, - { title: "Author Your First Flag", content: "

Open flags.json and add a flag named vision_state with two string variants (for example 'blurry' and 'clouded') and a defaultVariant. flagd's file watcher picks up changes within about a second, no restart needed.

" }, - { title: "Wire the Evaluation", content: "

Replace the hard-coded return in Trial with an OpenFeature evaluation of vision_state, returning the full evaluation details (flag key, variant, value, reason).

" }, - { title: "Test Hot Reload", content: `

Restart the lab. Confirm the value resolves from flags.json, then edit flags.json, change defaultVariant, save, and re-run curl without restarting anything:

-
curl -s http://localhost:8080/ | jq
-
` }, - ], - helpfulLinks: [ - { title: "OpenFeature Java SDK", url: "https://openfeature.dev/docs/reference/technologies/server/java/" }, - { title: "flagd Java provider", url: "https://github.com/open-feature/java-sdk-contrib/tree/main/providers/flagd" }, - { title: "flagd flag definitions", url: "https://flagd.dev/reference/flag-definitions/" }, - ], - 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.", - }, - metaDescription: "Wire the OpenFeature Java SDK and flagd provider into a Spring Boot service. Author a flag in flags.json and prove hot-reload works without restarting the app.", - }, - { - id: "intermediate", - name: "Outcome by Cohort", - difficulty: "Intermediate", - topics: ["OpenFeature", "flagd", "Spring Boot", "Java"], - learnings: [ - "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", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F04-blind-by-design_02-intermediate%2Fdevcontainer.json&quickstart=1`, - discussionUrl: `${COMMUNITY_URL}/t/outcome-by-cohort-adventure-04-intermediate/1485`, - deadline: "2026-05-26T23:59:00+01:00", - intro: [ - "Populate all three OpenFeature evaluation-context layers on a Spring Boot service and register an AuditHook. Transaction context comes from a HandlerInterceptor, global context from the COUNTRY environment variable at startup, and invocation context at the call site. The targeting in flags.json already has three branches for species, dose, and country, but none fire yet because the context layers are missing.", - ], - backstory: [ - "The trial is widening. Subjects from outside the lab's local population are getting the wrong reading on their chart, and the lab director has just walked in holding a stack of complaint forms. She wants the audit log to tell her, after the fact, exactly which vision_state the lab recorded for which subject, and she wants the lab to read the chart properly before it records any more bad readings.", - "The protocol is the same for every subject; the lab is not varying the trial. What differs is the observed outcome: some subjects have a biology that responds enhancedly to the same serum, some absorb less or more than the protocol's standard dose, and the trial is registered in different jurisdictions with different baselines.", - "Your shift: teach the lab to read each subject's species off the request, attach the trial's country of registration (set on the JVMJava Virtual Machine via the COUNTRY environment variable) to the global context, pass the dose as invocation context at the moment of the flag evaluation, and register an audit hook that records every dose with its variant and reason.", - ], - objective: [ - "curl /?species=zyklop returns 'enhanced' regardless of dose or country", - "With COUNTRY=de, curl /?dose=standard returns 'sharp'; with COUNTRY=at, the same call falls through to the default", - "curl /?dose=underdose returns 'clouded'; curl /?species=zyklop&dose=underdose returns 'enhanced' (species takes precedence)", - "Every evaluation produces an [AUDIT] log line naming the flag, the resolved variant, the reason, and the attributes that drove the outcome", - "The response is never 'untreated' (that fallback only fires when the SDKSoftware Development Kit cannot reach flagd)", - ], - architectureDiagram: blindByDesignIntermediate, - diagramAlt: "HTTP flows through SpeciesInterceptor, Trial, and OpenFeature client left to right, then down through AuditHook and FlagdProvider, connecting via gRPC to a flagd sidecar.", - toolbox: [ - { name: "Java 21 (Temurin)", description: "pre-installed in the devcontainer" }, - { name: "./mvnw", description: "Spring Boot Maven Wrapper, no global Maven install required" }, - { name: "curl", description: "sends requests to http://localhost:8080/ to test each targeting branch", url: "https://curl.se/" }, - { name: "jq", description: "pretty-prints the JSON evaluation details", url: "https://jqlang.org/" }, - { name: "tail -f", description: "watches the application log live for [AUDIT] lines" }, - ], - howToPlay: [ - { title: "Wait for Setup", content: "

Wait ~2-3 minutes for the Java toolchain to install. Use Cmd/Ctrl + Shift + P then View Creation Log to watch progress. When the post-create finishes you'll have Java 21, the Maven wrapper, and the broken-state lab ready in adventures/04-blind-by-design/intermediate/.

" }, - { title: "Explore the UIs", content: `

Open the Ports tab and navigate to each service:

-
    -
  • Port 8080: Spring Boot lab. The application under test. Access via the Ports tab or curl http://localhost:8080/.
  • -
` }, - { title: "Confirm the Broken State", content: `

Start the lab and confirm the broken state, where no targeting fires yet:

-
./mvnw spring-boot:run
-curl 'http://localhost:8080/?species=zyklop'
-# returns 'blurry', wrong cohort, targeting can't fire
-
-

That "blurry" is the starting point you want: even when the request shouts species=zyklop, the lab has nothing in its evaluation context, so flagd's targeting cannot fire and every subject drops to the default variant. Stop the app and start fixing.

` }, - { title: "Inspect the Starting Point", content: `

The lab already has the OpenFeature SDK and the flagd contrib provider on the classpath, and the FlagdProvider is wired in Resolver.RPC mode against the flagd sidecar. Open flags.json and inspect the targeting. Three branches exist but none fire because nothing in the app populates species, country, or dose yet:

-
"targeting": {
-  "if": [
-    { "===": [{"var": "species"}, "zyklop"] },        "enhanced",
-    { "in":  [{"var": "dose"}, ["underdose", "overdose"]] }, "clouded",
-    { "===": [{"var": "country"}, "de"] },             "sharp"
-  ]
-}
-
-

Your job: populate species, country, and dose on the evaluation context so the targeting fires.

` }, - { title: "Build the SpeciesInterceptor", content: "

Create a Spring HandlerInterceptor named SpeciesInterceptor: in preHandle, read ?species= and put it on the transaction context; in afterCompletion, clear the context so values do not leak across pooled threads. Register a ThreadLocalTransactionContextPropagator once on the OpenFeature API in a static initializer. Without the propagator the SDK has no way to carry per-request context across the call into the controller, and the transaction context silently stays empty.

" }, - { title: "Wire OpenFeatureConfig", content: "

Update OpenFeatureConfig to: register SpeciesInterceptor with Spring (WebMvcConfigurer.addInterceptors), read the COUNTRY environment variable and set it as the global evaluation context, and register AuditHook globally on the OpenFeature API. The three context layers, global (country), transaction (species from the interceptor), and invocation (dose from Trial), merge before flagd evaluates the rules. Precedence on conflict is invocation over transaction over global.

" }, - { title: "Update the Trial", content: "

Update Trial so each evaluation passes dose on the invocation context (the third argument to client.getStringDetails). Default to 'standard' most of the time but occasionally to 'underdose' or 'overdose', that is the lab tech mis-measuring, and it is what makes the improper-dosing branch in flags.json fire at all. Make it overridable via ?dose= so you can test each branch by hand. If the invocation context does not carry dose, the targeting rule sees null and the branch never fires: every non-zyklop request lands on either the country branch or the default.

" }, - { title: "Implement AuditHook", content: "

Implement AuditHook: in after(), write an [AUDIT] log line with flag name, variant, reason, and a fixed allowlist of attributes (species, country, dose). Log at WARN when the variant is 'clouded' so the safety officer can grep for it, otherwise INFO. Implement error() so failed evaluations are not silent. Use a fixed allowlist (List.of(\"species\", \"country\", \"dose\")) rather than iterating the whole context: audit logs outlive app logs, and logging only what you decided to log pays off the moment something sensitive lands on the context.

" }, - { title: "Test All Targeting Branches", content: `

Run the lab with country-specific scripts. These pipe output through tee app.log, which the verifier greps for [AUDIT] lines. If you run ./mvnw spring-boot:run directly, add | tee app.log or the verifier has nothing to grep:

-
./run-germany.sh    # COUNTRY=de  (or: make lab-germany)
-./run-austria.sh    # COUNTRY=at
-
-

Three named launch configs in .vscode/launch.json (Germany / Austria / No country) also let you switch cohorts from the Run and Debug view.

-

In another terminal, verify each branch:

-
curl -s 'http://localhost:8080/?species=zyklop' | jq .value
-# => "enhanced"
-
-curl -s 'http://localhost:8080/?dose=standard' | jq .value
-# => "sharp" (Germany) / "blurry" (Austria)
-
-curl -s 'http://localhost:8080/?dose=underdose' | jq .value
-# => "clouded"
-
-curl -s 'http://localhost:8080/?species=zyklop&dose=underdose' | jq .value
-# => "enhanced" (species wins)
-
-

Check the audit trail:

-
grep '\\[AUDIT\\]' app.log | head
-
-

You should see one [AUDIT] flag=vision_state variant=... reason=... species=... country=... dose=... line per request. clouded outcomes log at WARN.

` }, - ], - helpfulLinks: [ - { title: "OpenFeature Java SDK", url: "https://openfeature.dev/docs/reference/technologies/server/java/" }, - { title: "OpenFeature Hooks", url: "https://openfeature.dev/docs/reference/concepts/hooks" }, - { title: "Spring HandlerInterceptor", url: "https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/HandlerInterceptor.html" }, - { title: "flagd flag definitions", url: "https://flagd.dev/reference/flag-definitions/" }, - ], - 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.", - }, - metaDescription: "Add OpenFeature evaluation context and an AuditHook to a Spring Boot service. Target flag evaluations by species, country, and dose to record cohort outcomes.", - }, - { - id: "expert", - name: "Read the Chart", - difficulty: "Expert", - topics: ["OpenFeature", "OpenTelemetry", "Grafana", "Spring Boot"], - audience: "Platform engineers, SREsSite Reliability Engineers, and observability-focused developers who have completed the Beginner and Intermediate levels or are comfortable with OpenFeature evaluation context, and want to learn how flag evaluations join distributed traces and metrics, and how to use a flag flip as an operational lever for live rollbacks.", - learnings: [ - "How the OpenFeature OpenTelemetry hooks (TracesHook and MetricsHook) join flag evaluations to the rest of an application's telemetry without a separate ingestion path", - "How to author your own Hook: a tiny class that copies merged-eval-context attributes onto the active OTelOpenTelemetry 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", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F04-blind-by-design_03-expert%2Fdevcontainer.json&quickstart=1`, - discussionUrl: `${COMMUNITY_URL}/t/read-the-chart-adventure-04-expert/1530`, - deadline: "2026-05-26T23:59:00+01:00", - intro: [ - "Spans are already flowing into Tempo from the OpenFeature TracesHook, but the metrics half is dead: the MeterProvider has no exporter and the MetricsHook was never registered.", - "The dashboard the operator wants to triage from is empty. The k6 loadgen is idle, waiting for a flag flip to turn it on.", - ], - backstory: [ - "The trial just went wide. Phase 3 of the new vision amplifier (vision_amplifier_v2) was approved for the full cohort yesterday morning. The promise was straightforward: subjects emerge with sharper eyesight than they walked in with. By mid-afternoon the audit log was screaming. Subjects were stabilising 200ms slower, and roughly one in ten of them was emerging blind, with containment failure recorded as an HTTP 500. The lab director pulled up the Feature Flag Metrics dashboard expecting to triage visually. The dashboard was dark. Someone had wired up traces but never finished the metrics half. There is no chart to read. The lab is studying eyesight and the lab itself cannot see.", - "Your job, in order: turn on the lights, find the bad arm of the trial, and halt enrolment on the amplifier, all without redeploying the lab. That last constraint is the whole point of feature flags: when a rollout starts misbehaving in production, you need an operational lever that does not take twenty minutes to pull. Save the file, watch the dose drop, watch the 5xx rate fall back to baseline, watch the next batch of subjects walk out seeing.", - ], - objective: [ - "Spans for fun-with-flags-java-spring are visible in Tempo with feature_flag.context. attributes. Searching feature_flag.context.dose=underdose lights up requests where a subject was mis-dosed, with feature_flag.variant=clouded on the same span", - "feature_flag_evaluation_requests_total is non-zero in Prometheus: flag evaluations show up as counters, not just spans", - "The Feature Flag Metrics dashboard renders: variant distribution, error rate, and latency p99 are all populated from the metric counters", - "The vision_amplifier_v2 rollout is rolled back to 100% off without redeploying the lab", - "HTTP 5xx rate over the last minute drops below 1%: the bad arm is contained", - ], - architectureDiagram: blindByDesignExpert, - diagramAlt: "Architecture diagram showing four services: Spring Boot app sends traces via OTLP/gRPC to a Grafana LGTM stack, connects via OpenFeature SDK to flagd for feature flag evaluation, and a k6 load generator polls flagd and scrapes metrics from the LGTM stack.", - toolbox: [ - { name: "Java 21 (Temurin)", description: "pre-installed in the devcontainer", url: "https://adoptium.net/" }, - { name: "./mvnw", description: "Spring Boot Maven Wrapper, no global Maven install required" }, - { name: "curl", description: "sends requests to http://localhost:8080/ to test the lab, and to Prometheus on http://localhost:9090/ to query metrics directly", url: "https://curl.se/" }, - { name: "Grafana", description: "browser UIUser Interface at http://localhost:3000 (admin/admin) for the Feature Flag Metrics dashboard and Tempo trace explorer" }, - { name: "jq", description: "pretty-prints the JSON evaluation details", url: "https://jqlang.org/" }, - ], - howToPlay: [ - { title: "Start Your Challenge", content: "

The sibling containers (flagd, Grafana LGTMLoki, Grafana, Tempo, Mimir, k6 loadgen) start automatically as part of the devcontainer compose. Wait ~2-3 minutes for them to be ready before moving on.

" }, - { title: "Explore the UIs", content: `

Open the Ports tab and navigate to each service:

-
    -
  • Port 8080: Spring Boot lab. Add ?userId=subject-42 for a stable fractional-rollout bucketing key.
  • -
  • Port 3000: Grafana (admin / admin). Open Dashboards > Feature Flag Metrics (empty until metrics are wired). Try Explore > Tempo to see flag evaluations as span events.
  • -
  • Port 9090: Prometheus. Query metrics directly via the Prometheus UI or curl http://localhost:9090/api/v1/query.
  • -
  • Port 3200: Tempo. Tempo HTTP API used by the verify script to assert traces are flowing.
  • -
-

flagd runs on the docker-internal network only. No port forwarding needed.

` }, - { title: "Start the Lab", content: `

The sibling containers are already up. Boot the Spring Boot lab by clicking Run on Laboratory in the Spring Boot Dashboard panel (or press F5 with Laboratory.java open), or from the terminal:

-
./mvnw spring-boot:run
-
-

Spans start flowing into Tempo on the first request. The trace pipeline is already wired. The metrics pipeline is dead (task 4a), so the Grafana dashboard panels stay empty until you fix it.

` }, - { title: "Turn On the Metrics Exporter", content: `

OTel ships two parallel pipelines: traces (already flowing into Tempo) and metrics (dead). The OTel Java Agent attached to the lab JVMJava Virtual Machine has both pipelines plumbed and pointed at the LGTM stack, but otel.properties (next to pom.xml) sets otel.metrics.exporter=none, so anything the meter records goes nowhere.

-

Open otel.properties and flip the exporter on. While you're there, look at the export interval. The default makes the next steps harder than they need to be.

-

Once the exporter is on, MetricsHook (next step) finds the working meter provider through GlobalOpenTelemetry without any further plumbing. You will need to restart the lab to pick up the change.

` }, - { title: "Register MetricsHook", content: `

OpenFeatureConfig.java registers TracesHook but stops there. MetricsHook needs an OpenTelemetry handle to find the meter provider. The agent installs one globally at JVM start, so GlobalOpenTelemetry.get() is the way to reach it.

-

Register MetricsHook alongside TracesHook in OpenFeatureConfig. The Feature Flag Metrics dashboard stays empty until traffic drives through. That is what the loadgen step does.

` }, - { title: "Write and Register ContextSpanHook", content: `

The two contrib hooks tell you what happened: which flag, which variant, which reason. What is missing is the why visible in Tempo. Write a ContextSpanHook that copies the merged eval context attributes onto the active OTel span as feature_flag.context.<key>:

-
before(hookCtx) {
-    span = active OTel span
-    for each allowlisted key in merged eval context:
-        span.setAttribute("feature_flag.context." + key, value)
-}
-
-

HookContext.getCtx() returns the merged evaluation context (global + transaction + invocation). Use a fixed allowlist of List.of("species", "country", "dose"). Never iterate the whole context: targetingKey joins to PIIPersonally Identifiable Information in real apps, and span attributes are retained for days in Tempo at scale.

-

Register ContextSpanHook alongside TracesHook and MetricsHook in OpenFeatureConfig. The verifier searches Tempo for feature_flag.context.dose=underdose once you are done.

` }, - { title: "Turn On the Loadgen", content: `

flags.json has two flags: loadgen_active (off by default) and the misbehaving vision_amplifier_v2. flagd watches the file and picks up changes within about a second.

-

Flip loadgen_active to on. The k6 loadgen polls it every two seconds and starts five virtual users hammering the lab. Within a minute, latency p99 should climb ~200ms and the 5xx rate ~10% on the dashboard, confirming that the bad arm of vision_amplifier_v2 is active.

` }, - { title: "Roll Back the Rollout", content: `

The dashboard's variant-distribution panel shows which variant is the culprit. Roll it back by editing flags.json to set vision_amplifier_v2 to 100% off.

-

No deploy. No rebuild. No restart of the lab.

-

Watch the dashboard: the 5xx rate falls back to baseline, and the next batch of subjects walks out seeing.

` }, - ], - helpfulLinks: [ - { title: "OpenFeature OTel contrib hooks (Java)", url: "https://github.com/open-feature/java-sdk-contrib/tree/main/hooks/open-telemetry" }, - { title: "OpenTelemetry Java Agent configuration", url: "https://opentelemetry.io/docs/zero-code/java/agent/configuration/" }, - { title: "OpenFeature Hooks concept", url: "https://openfeature.dev/docs/reference/concepts/hooks" }, - { title: "flagd fractional operation", url: "https://flagd.dev/reference/custom-operations/fractional-operation/" }, - { title: "OpenTelemetry security guidance", url: "https://opentelemetry.io/docs/security/" }, - ], - 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.", - }, - metaDescription: "Wire OpenTelemetry metrics into an OpenFeature Java app, author a ContextSpanHook, then roll back a bad rollout by flipping a flag in flags.json. No redeploy.", - }, - ], -}; diff --git a/src/data/adventures/building-cloudhaven.generated.ts b/src/data/adventures/building-cloudhaven.generated.ts deleted file mode 100644 index 0c93a150f..000000000 --- a/src/data/adventures/building-cloudhaven.generated.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { CODESPACES_BASE, COMMUNITY_URL } from "@/data/constants"; -import type { Adventure } from "./types"; - -export const BUILDING_CLOUDHAVEN: Adventure = { - id: "building-cloudhaven", - title: "Building CloudHaven", - icon: "Building2", - 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.", - metaDescription: "Building CloudHaven: a hands-on OpenTofu, Terraform, GitHub Actions adventure on OffOn.", - tags: ["OpenTofu", "Terraform", "GitHub Actions", "Trivy", "TDD"], - contributor: { - name: "Katharina Sick", - url: "https://ksick.dev/", - aboutHtml: "DevRelDeveloper Relations at Dynatrace and co-organizer of Cloud Native Linz. Passionate about building user-friendly Cloud Native and Kubernetes solutions, with a background in mobile and backend development. Found in tech and sports communities, inline skating rinks, and quiz nights across Europe.", - }, - backstory: [ - "Welcome to CloudHaven, a bustling digital metropolis where every district depends on essential services to thrive. You've just joined the Infrastructure Guild, a team of platform engineers responsible for providing the tools and services that keep the city running.", - "CloudHaven is expanding rapidly. The Merchant's Quarter needs storage vaults for their goods and ledgers for tracking inventory. The Scholar's District requires secure archives for ancient texts. The Artisan's Quarter demands workshops with specialized tools. Each district has unique needs, but they all depend on the Guild to provide reliable, scalable infrastructure services.", - "The Guild used to provision everything manually through cloud consoles, a process that was slow, error-prone, and impossible to track. Recently, they've started adopting Infrastructure as Code, but the transition is incomplete.", - "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", - name: "The Foundation Stones", - difficulty: "Beginner", - topics: ["OpenTofu"], - learnings: [ - "Infrastructure as Code with OpenTofu", - "Remote state management with GCSGoogle Cloud Storage backend", - "Dynamic resource provisioning with for_each", - "Conditional resources with the enabled meta-argument, new in OpenTofu", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F02-building-cloudhaven_01-beginner%2Fdevcontainer.json&quickstart=1`, - discussionUrl: `${COMMUNITY_URL}/t/practice-infrastructure-as-code-with-zero-setup-adventure-02-beginner/656`, - deadline: "2026-02-04T23:59:00+01:00", - intro: [ - "An incomplete OpenTofu configuration is blocking the Merchant's Quarter from going live. Fix the broken backend, wire up dynamic resource provisioning with for_each, and use the new enabled meta-argument to conditionally deploy the audit database.", - ], - backstory: [ - "The Merchant's Quarter needs essential services, but the previous Guild engineer left the OpenTofu configuration incomplete and misconfigured. The state is stored locally, making collaboration impossible, and some services remain half-configured or missing.", - "Your mission: fix the issues, complete the setup, and establish proper infrastructure management for the Guild.", - ], - objective: [ - "Provision storage vaults and ledger databases for each district dynamically", - "Deploy the audit database only when there is more than one district", - "Store state remotely in a GCS backend following best practices so the Guild can collaborate", - "Resolve all TODOs in the code and successfully run tofu apply", - ], - toolbox: [ - { name: "tofu", description: "OpenTofu CLICommand Line Interface for infrastructure provisioning", url: "https://opentofu.org/" }, - { name: "gcp-api-mock", description: "mock GCP API running locally to simulate cloud resources without real cloud costs (Cloud Storage and Cloud SQL only)", url: "https://github.com/KatharinaSick/gcp-api-mock" }, - ], - howToPlay: [ - { title: "Wait for the Environment", content: "

Wait ~2 minutes for the environment to initialize.

" }, - { title: "Explore the UIs", content: `

Open the Ports tab and navigate to each service:

-
    -
  • Port 30104: GCP API Mock. Explore the mock cloud resources created by your configuration (Cloud Storage and Cloud SQL).
  • -
` }, - { title: "Find the TODOs", content: `

All OpenTofu files are in adventures/02-building-cloudhaven/beginner/. Run:

-
grep -r "TODO" .
-
-

Files to review: main.tf, state.tf, variables.tf, merchants.tf, audit.tf, outputs.tf.

` }, - { title: "Apply the Configuration", content: `

After fixing the TODOs, run:

-
tofu apply
-
-

If you changed the backend configuration, run tofu init -migrate-state first.

` }, - { title: "Run the Smoke Test", content: `

Run the smoke test to verify your solution:

-
./smoke-test.sh
-
` }, - ], - helpfulLinks: [ - { title: "OpenTofu documentation", url: "https://opentofu.org/docs/" }, - { title: "OpenTofu meta-arguments", url: "https://opentofu.org/docs/language/meta-arguments/count/" }, - { title: "OpenTofu backend configuration", url: "https://opentofu.org/docs/language/settings/backends/configuration/" }, - { title: "Google Cloud provider", url: "https://registry.terraform.io/providers/hashicorp/google/latest/docs" }, - ], - verification: { - command: "./smoke-test.sh", - description: "Once you think you've solved the challenge, run the smoke test to verify your solution.", - }, - metaDescription: "The Foundation Stones: An incomplete OpenTofu configuration is blocking the Merchant's Quarter from going live. Fix the broken backend, wire up dynamic...", - }, - { - id: "intermediate", - name: "The Modular Metropolis", - difficulty: "Intermediate", - topics: ["OpenTofu", "TDD"], - learnings: [ - "OpenTofu module testing with tofu test", - "Test-Driven Development (TDD) workflow", - "Input validation with custom rules", - "Refactoring infrastructure safely with moved blocks", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F02-building-cloudhaven_02-intermediate%2Fdevcontainer.json&quickstart=1`, - discussionUrl: `${COMMUNITY_URL}/t/adventure-02-building-cloudhaven-intermediate-the-modular-metropolis/723/10`, - deadline: "2026-02-04T23:59:00+01:00", - intro: [ - "A senior engineer wrote the tests first and then left. The module code is buggy and the integration test is incomplete. Fix the implementation to match the test expectations, complete the end-to-end test, and use moved blocks to refactor without destroying state.", - ], - backstory: [ - "After fixing the Foundation Stones, CloudHaven is thriving. The city has grown to three districts, and the Guild decided to refactor the infrastructure into reusable modules.", - "A senior engineer started the work using Test-Driven Development, writing tests first then implementing. But they were called away before finishing, leaving behind working tests and buggy code that doesn't match them.", - "Your mission: fix the bugs, complete the integration test, and deploy the infrastructure.", - ], - objective: [ - "All tests of the districts module pass", - "A completed integration test that applies infrastructure against the mock GCP API to verify end-to-end functionality", - "Three districts deployed with correctly configured infrastructure (vaults and ledgers)", - ], - toolbox: [ - { name: "tofu", description: "OpenTofu CLICommand Line Interface for infrastructure provisioning", url: "https://opentofu.org/" }, - { name: "gcp-api-mock", description: "mock GCP API running locally to simulate cloud resources without real cloud costs (Cloud Storage and Cloud SQL only)", url: "https://github.com/KatharinaSick/gcp-api-mock" }, - ], - howToPlay: [ - { title: "Wait for the Environment", content: "

Wait ~2 minutes for the environment to initialize.

" }, - { title: "Explore the UIs", content: `

Open the Ports tab and navigate to each service:

-
    -
  • Port 30104: GCP API Mock. Explore mock cloud resources to verify your module configuration.
  • -
` }, - { title: "Fix the Failing Tests", content: `

All files are in adventures/02-building-cloudhaven/intermediate/. The tests define the expected behaviour: your job is to fix the implementation to match what the tests expect. Don't modify existing tests unless a comment tells you to.

-
adventures/02-building-cloudhaven/intermediate/
-├── main.tf                    # Provider and backend configuration
-├── variables.tf               # Input variables
-├── districts.tf               # Module calls for each district
-├── outputs.tf                 # Infrastructure outputs
-├── moved.tf                   # Resource migration blocks
-├── modules/district/          # The district module (fix bugs here)
-│   ├── main.tf                # Locals and tier configuration
-│   ├── variables.tf           # Input validation
-│   ├── vault.tf               # Storage bucket resource
-│   ├── ledger.tf              # Cloud SQL resource
-│   ├── outputs.tf             # Module outputs
-│   └── tests/                 # Module tests (read these!)
-└── tests/
-    └── integration.tftest.hcl # Complete this test
-
-

Run tests to see what fails:

-
make test
-
` }, - { title: "Apply the Infrastructure", content: `

Once all tests pass, apply the infrastructure:

-
make test
-make apply
-
` }, - { title: "Run the Smoke Test", content: `

Run the smoke test to verify your solution:

-
./smoke-test.sh
-
` }, - ], - helpfulLinks: [ - { title: "OpenTofu testing", url: "https://opentofu.org/docs/cli/commands/test/" }, - { title: "OpenTofu modules", url: "https://opentofu.org/docs/language/modules/" }, - { title: "Input validation rules", url: "https://opentofu.org/docs/language/values/variables/#custom-validation-rules" }, - { title: "Moved blocks", url: "https://opentofu.org/docs/language/modules/develop/refactoring/" }, - ], - verification: { - command: "./smoke-test.sh", - description: "Once you think you've solved the challenge, run the smoke test to verify your solution.", - }, - metaDescription: "The Modular Metropolis: A senior engineer wrote the tests first and then left. The module code is buggy and the integration test is incomplete. Fix the...", - }, - { - id: "expert", - name: "The Guardian Protocols", - difficulty: "Expert", - topics: ["OpenTofu", "GitHub Actions", "Trivy"], - learnings: [ - "GitHub Actions for drift detection and plan/apply", - "Integration tests with service containers", - "Security scanning with Trivy", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F02-building-cloudhaven_03-expert%2Fdevcontainer.json&quickstart=1`, - discussionUrl: `${COMMUNITY_URL}/t/adventure-02-building-cloudhaven-expert-the-guardian-protocols/782/8`, - deadline: "2026-02-04T23:59:00+01:00", - intro: [ - "Three broken GitHub Actions workflows stand between CloudHaven and automated infrastructure governance. Fix drift detection that creates PRs, PRPull Request validation with Trivy security scanning and service-container integration tests, and automatic apply on merge.", - ], - backstory: [ - "After the Modular Metropolis refactoring, CloudHaven flourished. But with growth came risk. One night, a rogue change slipped through unnoticed and nearly brought down the North Market's trading vaults. The Council was furious: how could this happen without anyone noticing?", - "The Guild Master summoned you urgently. \"We need guardians,\" she said. \"Automated sentinels that watch over our infrastructure day and night. They must catch dangerous changes before they reach the city, detect when reality drifts from our blueprints, and sound the alarm when threats appear.\"", - "A previous engineer began building these Guardian Protocols using GitHub Actions, but was reassigned before completing them. The workflows exist, but they're incomplete and broken.", - "Your mission: bring the Guardian Protocols online and protect CloudHaven from chaos.", - ], - objective: [ - "Drift detection: run tofu plan and create a PR when drift is found", - "PR validation: run tofu plan and comment results, run integration tests against the mock GCP API, scan for security vulnerabilities with Trivy and fail on critical or high severity issues", - "Automatic apply: apply infrastructure when a PR is merged to main", - "All three workflows must have succeeded at least once", - ], - toolbox: [ - { name: "tofu", description: "OpenTofu CLICommand Line Interface for infrastructure provisioning", url: "https://opentofu.org/" }, - { name: "gcp-api-mock", description: "mock GCP API running locally (port set to public so GitHub Actions runners can access it)", url: "https://github.com/KatharinaSick/gcp-api-mock" }, - { name: "GitHub Actions", description: "the workflows you will fix are in .github/workflows/", url: "https://docs.github.com/en/actions" }, - ], - howToPlay: [ - { title: "Wait for the Environment", content: "

Wait ~2 minutes for the environment to initialize.

" }, - { title: "Explore the UIs", content: `

Open the Ports tab and navigate to each service:

-
    -
  • Port 30104: GCP API Mock. Port is set to public so GitHub Actions runners can reach it during workflow runs. You may see a browser security warning. Click Continue to proceed.
  • -
` }, - { title: "Fix the Workflows", content: `

Fix the three workflows in .github/workflows/:

-
    -
  • adventure02-expert-detect-drift.yaml
  • -
  • adventure02-expert-validate-changes.yaml
  • -
  • adventure02-expert-apply-infrastructure.yaml
  • -
-

The OpenTofu configuration is correct, focus only on the workflow files.

` }, - { title: "Trigger Drift Detection", content: "

Commit and push to main. Go to the Actions tab, select the drift detection workflow, and click Run workflow. The infrastructure has intentional drift, so the workflow should create a draft PR.

" }, - { title: "Trigger Validation", content: "

Click Ready for Review on the draft PR to trigger the validation workflow. To re-trigger validation after pushing new changes, convert the PR back to draft then Ready for Review again. Re-running a failed workflow uses the code from the original run, so toggling draft state is how you pick up new changes pushed to main.

" }, - { title: "Merge and Apply", content: "

When the PR is merged to main, the apply workflow runs automatically.

" }, - { title: "Run the Smoke Test", content: `

Run the smoke test to verify your solution:

-
cd adventures/02-building-cloudhaven/expert
-./smoke-test.sh
-
` }, - ], - helpfulLinks: [ - { title: "GitHub Actions documentation", url: "https://docs.github.com/en/actions" }, - { title: "GitHub Actions service containers", url: "https://docs.github.com/en/actions/use-cases-and-examples/using-containerized-services/about-service-containers" }, - { title: "OpenTofu plan command", url: "https://opentofu.org/docs/cli/commands/plan/" }, - { title: "Trivy action", url: "https://github.com/aquasecurity/trivy-action" }, - { title: "TF-via-PR action", url: "https://github.com/OP5dev/TF-via-PR" }, - ], - verification: { - command: "./smoke-test.sh", - description: "Once you think you've solved the challenge, run the smoke test to verify your solution.", - }, - metaDescription: "The Guardian Protocols: Three broken GitHub Actions workflows stand between CloudHaven and automated infrastructure governance. Fix drift detection that...", - }, - ], -}; diff --git a/src/data/adventures/contributors.ts b/src/data/adventures/contributors.ts index e8fcf678c..1f66f57b6 100644 --- a/src/data/adventures/contributors.ts +++ b/src/data/adventures/contributors.ts @@ -3,5 +3,5 @@ import type { Adventure } from "./types"; export const KATHARINA_SICK: NonNullable = { name: "Katharina Sick", url: "https://ksick.dev/", - about: "Senior Developer Programs Engineer at Dynatrace and co-organizer of Cloud Native Linz. Passionate about building user-friendly Cloud Native and Kubernetes solutions, with a background in mobile and backend development. Found in tech and sports communities, inline skating rinks, and quiz nights across Europe.", + aboutHtml: "Senior Developer Programs Engineer at Dynatrace and co-organizer of Cloud Native Linz. Passionate about building user-friendly Cloud Native and Kubernetes solutions, with a background in mobile and backend development. Found in tech and sports communities, inline skating rinks, and quiz nights across Europe.", }; diff --git a/src/data/adventures/dead-reckoning.generated.ts b/src/data/adventures/dead-reckoning.generated.ts deleted file mode 100644 index a72f967ec..000000000 --- a/src/data/adventures/dead-reckoning.generated.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { CODESPACES_BASE, COMMUNITY_URL } from "@/data/constants"; -import deadReckoningIntermediate from "@/assets/diagrams/dead-reckoning-intermediate.svg"; -import deadReckoningExpert from "@/assets/diagrams/dead-reckoning-expert.svg"; -import type { Adventure } from "./types"; - -export const DEAD_RECKONING: Adventure = { - id: "dead-reckoning", - title: "Dead Reckoning", - icon: "Compass", - month: "JUL 2026", - story: "The Grand Fleet's commission office is buried in complaints. Manifests are filed but nothing comes of them. Vessels that do sail arrive at port with the wrong cargo, and no one along the route can explain why. As the fleet's engineer, your mission is to restore order from keel to quayside and find out what the records are hiding.", - metaDescription: "Fix a broken Backstage software template: debug the scaffolder steps that create a Gitea repository and register services in the catalog.", - tags: ["Backstage", "Gitea", "Argo Events", "Argo Workflows", "Argo CD"], - contributor: { - name: "Katharina Sick", - url: "https://ksick.dev/", - aboutHtml: "DevRelDeveloper Relations at Dynatrace and co-organizer of Cloud Native Linz. Passionate about building user-friendly Cloud Native and Kubernetes solutions, with a background in mobile and backend development. Found in tech and sports communities, inline skating rinks, and quiz nights across Europe.", - }, - backstory: [ - "The Grand Fleet's commission office is buried in complaints. Manifests are filed but nothing comes of them. Vessels that do sail arrive at port with the wrong cargo, and no one along the route can explain why. As the fleet's engineer, your mission is to restore order from keel to quayside and find out what the records are hiding.", - ], - rewards: { - deadline: "2026-07-28T23:59:00+01:00", - 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: "Laying the Keel", - difficulty: "Beginner", - topics: ["Backstage", "Gitea"], - audience: "Platform engineers, developers, and anyone curious about internal developer platforms and self-service scaffolding. No prior Backstage experience is needed, but familiarity with YAML and basic Git concepts will help.", - learnings: [ - "How Backstage software templates are structured: parameters, steps, and output", - "How scaffolder actions work, such as fetch:template, publish:gitea, and catalog:register", - "How the catalog registration step connects a scaffolded repository to the Backstage catalog", - "How to use Backstage's built-in template tooling: the installed-actions browser and the Template Editor's live preview and dry-run", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2Fdead-reckoning_beginner%2Fdevcontainer.json&quickstart=1`, - discussionUrl: "https://community.offon.dev/t/repair-a-broken-backstage-software-template-july-2026-adventure-beginner/1657", - deadline: "2026-07-28T23:59:00+01:00", - intro: [ - "Fix a broken Backstage software template so the commission office can register new vessels for service.", - ], - backstory: [ - "The commission office has been open for weeks, but nothing is being processed. Captains submit their manifests to register a new vessel, wait, and hear nothing back. No repository of record ever appears in the archives, and the vessel never makes it into the fleet registry.", - "The commissioning procedure is supposed to be routine: take a captain's request, open a fresh set of records for the vessel in the archives, and enter the new ship into the registry so the rest of the yards can pick up the work. Somewhere in that procedure, a step is misconfigured, and every commission fails before it completes.", - "Your mission: repair the vessel commissioning procedure so the office can register new vessels again, from the captain's request all the way to a proper entry in the registry.", - ], - objective: [ - "Commission a vessel end to end: file its repository at the location picked in the form (not a hardcoded path) so the new service is registered in the Backstage catalog", - "In the commissioning form, choose the owning squadron from a picker of the catalog's squadrons, instead of typing it in by hand", - "From the commissioning result, follow a working link to the new component in the catalog", - ], - architecture: [ - "

A Backstage software template mirrors the story's commissioning chain: it gathers input, then runs scaffolder steps that render the service's files (fetch:template), create and push a repository in Gitea (publish:gitea), and register the new component in the catalog (catalog:register). If one step is misconfigured, the whole commission fails.

", - "

All infrastructure is pre-provisioned, with nothing to install. Gitea (the archives) runs in a Kubernetes cluster on port 30110; Backstage (the commission office) runs alongside as a standalone instance on port 3000, already wired to Gitea.

", - "

Good news: you can trust the platform. The Backstage app and its configuration, Gitea, and the cluster are all set up correctly, so you can leave them be. The bug lives in the vessel commissioning template, and that is the only thing you need to touch.

", - "

Note: this Backstage has been trimmed to just what the challenge needs (the catalog and the scaffolder), so it is deliberately lighter than a full install. If some Backstage page or feature you would expect is missing, that is why.

", - ], - toolbox: [ - { name: "Backstage", description: "The commission office. Run the template from Create, and repair it with the Template Editor's live preview and dry-run.", url: "https://backstage.io/docs/features/software-templates/" }, - { name: "Gitea", description: "The archives. Where a commissioned vessel's repository is created; check it to see what the template produced.", url: "https://docs.gitea.com/" }, - ], - howToPlay: [ - { title: "Open the Commission Office", content: `

Start Backstage with make backstage. The first run compiles for ~30-60s; once it's up, the -commission office is available in your browser on port 3000. Leave it running: you'll see the -logs in that terminal, and can restart any time with Ctrl-C then make backstage.

` }, - { title: "Explore the Setup", content: `

In the office (Backstage), go to Create: you'll find the Commission a Vessel template. -Try running it: it won't get far, and that's expected. The template is broken, and your job is -to repair it.

-

While you're in Create, explore its tabs: they're genuinely useful when working on -templates, letting you browse the scaffolder's available actions and edit a template with a -live preview and a safe dry-run. Poke around and see what each one does.

-

Open Gitea on port 30110 (the archives) and the Backstage catalog to see what does, -and doesn't, make it through when you run the template.

` }, - { title: "Repair the Template", content: `

The only file you need to edit is the vessel commissioning template:

-
backstage/templates/vessel-commissioning/template.yaml
-
-

Read it from top to bottom. Its three sections, parameters (the form), steps (the -commissioning procedure), and output (what the captain sees at the end), each have a -📖 documentation link above them. Compare each section against the Objective: -the form, the steps, and the output each have something to put right.

-

Make your changes, try again, and once a vessel commissions cleanly, check your work. -Keep Backstage running in its terminal while you do: make verify commissions a test -vessel through it to confirm the repair.

-
make verify
-
` }, - ], - helpfulLinks: [ - { title: "Backstage Software Templates", url: "https://backstage.io/docs/features/software-templates/", description: "Overview of the scaffolder and how self-service templates work in Backstage" }, - { title: "Writing Templates", url: "https://backstage.io/docs/features/software-templates/writing-templates", description: "How a template's parameters, steps, and output fit together: the structure you'll be fixing" }, - { title: "Built-in Scaffolder Actions", url: "https://backstage.io/docs/features/software-templates/builtin-actions", description: "Reference for the actions a template can run, including publish and catalog registration steps" }, - ], - 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.", - }, - metaDescription: "Laying the Keel: Fix a broken Backstage software template so the commission office can register new vessels for service.", - }, - { - id: "intermediate", - name: "Sea Trial", - difficulty: "Intermediate", - topics: ["Backstage", "Gitea", "Argo Events", "Argo Workflows", "Argo CD"], - audience: "Platform and DevOpsDevelopment and Operations engineers who have met these tools before and want to see how they fit together. You should be comfortable with Kubernetes, YAML, and reading a tool's logs and UIUser Interface. Prior exposure to Backstage, Gitea, and the Argo projects helps, but the focus here is the integration between them, not any one tool.", - learnings: [ - "How a Git webhook drives a workflow engine: Argo Events Sensors turn a push into a parameterized workflow run", - "How Argo Workflows runs a multi-step delivery pipeline, and the RBAC its steps need", - "How an Argo CD ApplicationSet auto-discovers repos and syncs them into the cluster", - "How Backstage annotations tie a catalog entity to its live deployment status", - "How to trace a silent failure across tools from each one's logs and UI", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2Fdead-reckoning_intermediate%2Fdevcontainer.json&quickstart=1&machine=standardLinux32gb`, - discussionUrl: "https://community.offon.dev/t/fix-a-broken-delivery-pipeline-july-2026-adventure-intermediate/1668", - deadline: "2026-07-28T23:59:00+01:00", - intro: [ - "Fix the broken integration points in the delivery pipeline so that commissioning a vessel in Backstage results in a running deployment.", - ], - backstory: [ - "The commission office is back in business: manifests are filed, repositories are created, and every new vessel is entered into the fleet registry. Yet the captains keep coming back with the same complaint. Their vessels are commissioned on paper, but they never actually sail.", - "Between the commission office and open water lies a chain of yards, each meant to pick up where the last left off: a push to the archives should summon the shipyard, the shipyard should build the hull from the plans and hand it to the harbor master, and the harbor master should bring the finished vessel into formation. Somewhere along that chain the handoffs are misconfigured, and every vessel stalls before it reaches the water.", - "Your mission: trace a single commission from the office all the way to open water, find every point where the chain is broken, and repair the integrations so that commissioning a vessel results in a running ship you can reach directly.", - ], - objective: [ - "A commissioned vessel is fully delivered: its code and deployment repositories exist, its delivery workflow has completed, its Argo CD Application is synced, and its service is running in the cluster", - "See the vessel's live deployment status on its page in Backstage", - "The vessel's service is reachable directly and reports itself seaworthy", - ], - architecture: [ - "

Commissioning a vessel kicks off a delivery pipeline that spans several tools. Backstage files two repositories in Gitea (the vessel's code and its deployment manifests). A push to the code repository fires a Gitea webhook into Argo Events, whose Sensor submits an Argo Workflow. That workflow clones the code, builds a container image and pushes it to Gitea's registry, then stamps the new image tag onto the deployment repository. Argo CD discovers the deployment repository and syncs it into the cluster as a running Deployment and Service. The diagram below shows the full chain.

", - "

Each vessel's page in Backstage is your cockpit: it shows the vessel's live Argo CD sync and health and its delivery workflows, so you can watch a commission progress and spot where it stalls. Alongside it, use each tool's own view: Gitea's repositories and a webhook's Recent Deliveries, the Argo Workflows UI (port 30113), and the Argo CD UI (port 30100).

", - "

You edit two things: the delivery pipeline manifests under platform/ (argo-events/, argo-workflows/, argocd/) and the vessel commissioning template under backstage/templates/vessel-commissioning-template/.

", - "

This level runs a real container build on a Kubernetes cluster. It works on the default Codespace, but if you hit resource problems or the pipeline feels sluggish, upgrade to a larger (4-core / 16 GB) machine. Either way, give the pipeline a few minutes after commissioning: the first build pulls its base images before an image ever reaches the cluster.

", - `
-

A quick translation from the story to the tools: a vessel is a small service you deploy, made up of its code repository, its deployment manifests, and the running Deployment and Service in the cluster. The commission office is Backstage, the archives are Gitea, the shipyard is Argo Workflows, and the harbor master is Argo CD; Argo Events is the lookout that summons the shipyard when new code is filed. A vessel "reaching open water" just means a commissioned service made it all the way to a running, reachable deployment.

-
`, - ], - architectureDiagram: deadReckoningIntermediate, - diagramAlt: "Left-to-right delivery pipeline: Backstage creates the repositories, Argo Events triggers the CI build, Argo Workflows builds the image and updates the tag, Argo CD syncs the deployment, and the vessel's app is up and running.", - toolbox: [ - { name: "Backstage", description: "The commission office and your cockpit (port 3000). Commission a vessel from Create, then watch its page for the vessel's Argo CD status and delivery workflows.", url: "https://backstage.io/docs/features/software-catalog/" }, - { name: "Gitea", description: "The archives (port 30112). Holds each vessel's code and deployment repositories, the org webhook, and the container registry. A webhook's Recent Deliveries shows whether a push was delivered.", url: "https://docs.gitea.com/" }, - { name: "Argo Workflows", description: "The shipyard (port 30113). Runs the multi-step build-and-deliver workflow; its UI shows each run, step by step, and why a step failed.", url: "https://argo-workflows.readthedocs.io/en/latest/" }, - { name: "Argo CD", description: "The harbor master (port 30100). Discovers deployment repositories and reconciles them into the cluster; its UI shows each vessel's sync and health.", url: "https://argo-cd.readthedocs.io/en/stable/" }, - ], - howToPlay: [ - { title: "Open the Commission Office", content: `

Start Backstage with make backstage. The first run compiles for ~30-60s; once it's up, the commission -office is available in your browser on port 3000. Leave it running in that terminal; restart any time with -Ctrl-C then make backstage.

-

The rest of the platform (Gitea, Argo Events, Argo Workflows, Argo CD) is already running in the cluster.

` }, - { title: "Explore the UIs", content: `

Open the Ports tab and navigate to each service:

-
    -
  • Port 3000: Backstage. The commission office and your cockpit. Start it with make backstage, then commission vessels and watch each one's page. Signs in as a guest, no credentials needed.
  • -
  • Port 30112: Gitea (admin / a-super-secure-password). The archives: each vessel's code and deployment repositories, the org webhook (Recent Deliveries), and the container registry.
  • -
  • Port 30113: Argo Workflows. The shipyard: the delivery workflow runs, step by step, with per-step logs. No sign-in required.
  • -
  • Port 30100: Argo CD (readonly / a-super-secure-password). The harbor master: each vessel's Application, sync status, and health.
  • -
` }, - { title: "Trace a Commission", content: `

In Backstage, go to Create and run the Commission a Vessel template. It will commission cleanly, but -the vessel won't sail. That's expected: your job is to find out why.

-

Open the new vessel's page in the catalog. It's your cockpit: it shows the vessel's Argo CD deployment status -and its delivery workflows. From there, follow the chain outward and see how far each commission gets:

-
    -
  • Gitea (port 30112): were both repositories created? Did the push reach the pipeline (check a webhook's -Recent Deliveries)?
  • -
  • Argo Workflows (port 30113): did a build run for this vessel, and if it failed, what does the failing -step report?
  • -
  • Argo CD (port 30100): is there an Application for the vessel, and is it synced and healthy?
  • -
-

Each tool shows you one handoff. Follow the data from one to the next until you find where it stalls.

` }, - { title: "Repair the Integrations", content: `

The breaks live in the delivery pipeline manifests and the commissioning template:

-
platform/argo-events/
-platform/argo-workflows/
-platform/argocd/
-backstage/templates/vessel-commissioning-template/
-
-

Read the relevant file top to bottom and compare it against what you observed. After editing a platform/ -manifest, re-apply it with make apply. Because the pipeline is triggered by a push, test a fix by -commissioning a fresh vessel (or redelivering the push from Gitea's Recent Deliveries). Changes to the -template take effect on the next vessel you commission.

-

When you think a vessel can sail end to end, check your work. make verify grades the most recently -commissioned vessel stage by stage and reports the first place the pipeline runs aground:

-
make verify
-
` }, - ], - helpfulLinks: [ - { title: "Backstage: Well-known Annotations", url: "https://backstage.io/docs/features/software-catalog/well-known-annotations", description: "How annotations on a catalog entity link it to external systems shown on its page" }, - { title: "Argo Events: Sensor", url: "https://argoproj.github.io/argo-events/concepts/sensor/", description: "How a Sensor listens for events and triggers actions, and how it shapes a trigger from the event's data" }, - { title: "Argo Workflows: Steps", url: "https://argo-workflows.readthedocs.io/en/latest/walk-through/steps/", description: "How a workflow chains multiple steps and passes data between them" }, - { title: "Argo Workflows: Workflow RBAC", url: "https://argo-workflows.readthedocs.io/en/latest/workflow-rbac/", description: "What permissions a workflow's service account needs for its steps to run and report status" }, - { title: "Argo CD: SCM Provider Generator", url: "https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/Generators-SCM-Provider/", description: "How an ApplicationSet auto-discovers repositories and turns each into an Application" }, - { title: "Argo CD: Automated Sync", url: "https://argo-cd.readthedocs.io/en/stable/user-guide/auto_sync/", description: "How Argo CD reconciles an Application's desired state into the cluster" }, - ], - verification: { - command: "make verify", - description: "Grades the most recently commissioned vessel stage by stage, from its repositories through the delivery workflow to a synced Argo CD Application and a reachable service, and reports the first place the pipeline runs aground.", - }, - metaDescription: "Sea Trial: Fix the broken integration points in the delivery pipeline so that commissioning a vessel in Backstage results in a running deployment.", - }, - { - id: "expert", - name: "The Chronometer", - difficulty: "Expert", - topics: ["Backstage", "Argo Workflows", "Argo CD", "OpenTelemetry", "Jaeger"], - audience: "Platform and DevOps engineers who already know how the delivery pipeline fits together and want to see how a distributed trace ties it into one story. You should be comfortable with Kubernetes, YAML, and reading a tool's logs and UI. Prior exposure to OpenTelemetry, trace context propagation, and a trace viewer like Jaeger helps, but the focus here is using a trace to reason across service boundaries, not any one tool.", - learnings: [ - "How trace context crosses an asynchronous boundary with no call chain: a commission carries its W3C traceparent to a push-triggered pipeline, so its spans continue the same trace instead of starting a new one", - "Why a distributed trace reveals what per-tool logs cannot: the value that flowed through each step, so a fault surfaces where the data diverges", - "How to read that trace in Jaeger to localise a fault to a single service by following one attribute across the whole voyage", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2Fdead-reckoning_expert%2Fdevcontainer.json&quickstart=1&machine=standardLinux32gb`, - discussionUrl: "https://community.offon.dev/t/trace-a-silent-failure-across-your-delivery-pipeline-july-2026-adventure-expert/1671", - deadline: "2026-07-28T23:59:00+01:00", - intro: [ - "Repair the fleet's broken navigation log, then use the complete trace it produces to find why vessels arrive carrying the wrong cargo.", - ], - backstory: [ - "Every commission the office issues is supposed to leave one clean line in the navigation log: the moment a captain fills in the papers, the yards that build and deliver the vessel, and the harbor master bringing it into formation, all recorded as a single voyage you can read end to end. Read the log and you know exactly what happened to any vessel, and when.", - "Lately the log lies. It records the commission office plainly enough, then goes dark the instant a vessel leaves for the yards: the shipyard's and harbor master's work never appears on the same line. And at the far end of the voyage, captains keep signing for the wrong cargo, salt-pork where citrus was ordered, though every yard along the route reported a clean, successful run. No single tool shows anything wrong.", - "Your mission: repair the log so a commission reads as one unbroken voyage again, from the office all the way to open water, then use that complete picture to find where the cargo goes astray, and set it right.", - ], - objective: [ - "Every commission appears in the navigation log (Jaeger) as a single, connected trace, unbroken from the commission office (Backstage) through the shipyard (Argo Workflows) and the harbor master (Argo CD)", - "Every vessel sails carrying the provisions it was commissioned to carry", - ], - architecture: [ - "

This level adds a tracing overlay to the Sea Trial delivery pipeline: every stage now reports spans to an OpenTelemetry Collector, which forwards them to Jaeger. The commission office opens the trace and hands its context to the pipeline, so one commission should read as a single connected voyage: commission <vessel> -> enter parameters and scaffold repos (Backstage) -> ci pipeline (Argo Workflows) -> rollout (Argo CD).

", - "

The cargo rides that trace at three checkpoints, so you see not just that it is wrong but where it changed: provisions.ordered (root span, what was selected), provisions.declared (rollout span, what the deployment sets), and provisions.reported (rollout span, what the running vessel says it carries). In a healthy voyage all three agree.

", - "

Your repairs live in the vessel commissioning template under backstage/templates/vessel-commissioning-template/ (template.yaml and the files under content/). Everything else, the platform/ manifests and the Backstage tracing setup, is working: read it to see how the trace is carried out to sea, but you won't need to change it.

", - "

This level runs a real container build, so your Codespace uses a larger (4-core / 16 GB) machine. Give the pipeline a few minutes after commissioning; the delivery spans land in Jaeger once the rollout completes.

", - `
-

A quick translation from the story to the tools: a vessel is a small service you deploy; its cargo (provisions) is a value it is commissioned to carry, set in its deployment and reported by the running service. The commission office is Backstage, the shipyard is Argo Workflows, the harbor master is Argo CD, and the navigation log is Jaeger. A commission's voyage is the single distributed trace that should span all of them.

-
`, - ], - architectureDiagram: deadReckoningExpert, - diagramAlt: "The delivery pipeline runs left to right across the top: Backstage creates the repositories, Argo Events triggers the CI build, Argo Workflows builds the image and updates the tag, Argo CD syncs the deployment, and the vessel's app is up and running. A W3C traceparent thread runs through the pipeline. Backstage, Argo Workflows, and Argo CD each report spans down to the OpenTelemetry Collector, which feeds Jaeger, where a single commission appears as one connected trace: a Commission Vessel root span over Backstage, Argo Workflows, and Argo CD spans.", - toolbox: [ - { name: "Jaeger", description: "The navigation log (port 30103). Shows each commission's trace; search by service backstage and operation commission <vessel> to open a voyage, then read its spans and their attributes.", url: "https://www.jaegertracing.io/docs/latest/" }, - { name: "Backstage", description: "The commission office and your cockpit (port 3000). Commission a vessel from Create; its page carries a Voyage log card that links to the trace.", url: "https://backstage.io/docs/features/software-templates/" }, - { name: "Argo Workflows", description: "The shipyard (port 30113). Runs the multi-step build-and-deliver workflow and emits the ci pipeline spans.", url: "https://argo-workflows.readthedocs.io/en/latest/" }, - { name: "Argo CD", description: "The harbor master (port 30100). Reconciles each vessel into the cluster and emits the rollout span.", url: "https://argo-cd.readthedocs.io/en/stable/" }, - { name: "OpenTelemetry Collector", description: "The signal station every component reports spans to; it forwards them on to Jaeger. Part of the working tracing setup, here to read, not to change.", url: "https://opentelemetry.io/docs/collector/" }, - ], - howToPlay: [ - { title: "Open the Commission Office", content: `

Start Backstage with make backstage. The first run compiles for ~30-60s; once it's up, the commission -office is available in your browser on port 3000. Leave it running in that terminal; restart any time with -Ctrl-C then make backstage.

-

The rest of the platform (Gitea, Argo Events, Argo Workflows, Argo CD, the OpenTelemetry Collector, and Jaeger) -is already running in the cluster.

` }, - { title: "Explore the UIs", content: `

Open the Ports tab and navigate to each service:

-
    -
  • Port 3000: Backstage. The commission office and your cockpit. Start it with make backstage, then commission vessels. Signs in as a guest, no credentials needed.
  • -
  • Port 30103: Jaeger. The navigation log: each commission's distributed trace, across Backstage, Argo Workflows, and Argo CD. No sign-in required.
  • -
  • Port 30112: Gitea (admin / a-super-secure-password). The archives: each vessel's code and deployment repositories and the container registry.
  • -
  • Port 30113: Argo Workflows. The shipyard: the delivery workflow runs, step by step, with per-step logs. No sign-in required.
  • -
  • Port 30100: Argo CD (readonly / a-super-secure-password). The harbor master: each vessel's Application, sync status, and health.
  • -
` }, - { title: "Sail a Commission and Read the Log", content: `

In Backstage, go to Create and run the Commission a Vessel template. Pick a cargo you'll remember (say -Citrus), and let the vessel sail. Delivery still works, but the records don't add up. You have two -instruments to check what really happened:

-
    -
  • Greet the vessel. Once it's running, make ahoi reports the cargo it's actually carrying. -Is it what you ordered?
  • -
  • Open the log. In Jaeger (port 30103), search service backstage, operation commission <vessel>, -and open the trace. Read it from the commission office outward: how much of the voyage actually reached the -log?
  • -
-

The trace is your instrument for both. It carries the cargo along the voyage (provisions.ordered -> -provisions.declared -> provisions.reported), so once the log reads true end to end, Jaeger is where you -follow the cargo from order to arrival and find where it goes astray.

` }, - { title: "Repair the Log, Then the Cargo", content: `

Both repairs live in the vessel commissioning template:

-
backstage/templates/vessel-commissioning-template/
-
-

Start with the log. Read how a commission carries its trace context out to the pipeline, and how the pipeline -picks it back up off the push it reacts to; the two need to line up on the same commit. With the voyage whole, -the trace shows the cargo at each checkpoint (provisions.ordered -> provisions.declared -> -provisions.reported): follow it and let it tell you which stage the cargo survives and which one it doesn't.

-

Test a fix by commissioning a fresh vessel and watching it sail. If you edited template.yaml, reload it into -Backstage first so the next commission picks up your change:

-
make reload-template
-
` }, - ], - helpfulLinks: [ - { title: "W3C Trace Context", url: "https://www.w3.org/TR/trace-context/", description: "The traceparent format that carries a trace across service boundaries" }, - { title: "OpenTelemetry: Context Propagation", url: "https://opentelemetry.io/docs/concepts/context-propagation/", description: "How spans in different processes are stitched into one trace by passing context between them" }, - { title: "OpenTelemetry: Traces", url: "https://opentelemetry.io/docs/concepts/signals/traces/", description: "What a trace and its spans are, and why they capture causal, data-carrying detail logs don't" }, - { title: "Jaeger: Documentation", url: "https://www.jaegertracing.io/docs/latest/", description: "Searching for and reading distributed traces across services" }, - { title: "Backstage: Software Templates", url: "https://backstage.io/docs/features/software-templates/", description: "How a scaffolder template runs its steps and passes values between them" }, - ], - verification: { - command: "make verify", - description: "Grades the most recently commissioned vessel against the two end-states: its commission is one connected trace in Jaeger, and the running vessel reports the cargo its deployment declares. Each check reports on its own, so you see which half of the log still reads false.", - }, - metaDescription: "The Chronometer: Repair the fleet's broken navigation log, then use the complete trace it produces to find why vessels arrive carrying the wrong cargo.", - }, - ], -}; diff --git a/src/data/adventures/echoes-lost-in-orbit.generated.ts b/src/data/adventures/echoes-lost-in-orbit.generated.ts deleted file mode 100644 index 632d0f2cf..000000000 --- a/src/data/adventures/echoes-lost-in-orbit.generated.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { CODESPACES_BASE, COMMUNITY_URL } from "@/data/constants"; -import type { Adventure } from "./types"; - -export const ECHOES_LOST_IN_ORBIT: Adventure = { - id: "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.", - metaDescription: "Echoes Lost in Orbit: a hands-on Argo CD, Argo Rollouts, OpenTelemetry adventure on OffOn.", - tags: ["Argo CD", "Argo Rollouts", "OpenTelemetry", "Jaeger", "PromQL"], - contributor: { - name: "Katharina Sick", - url: "https://ksick.dev/", - aboutHtml: "DevRelDeveloper Relations at Dynatrace and co-organizer of Cloud Native Linz. Passionate about building user-friendly Cloud Native and Kubernetes solutions, with a background in mobile and backend development. Found in tech and sports communities, inline skating rinks, and quiz nights across Europe.", - }, - backstory: [ - "Welcome aboard the GitOpsGit Operations Starliner, a multi-species engineering vessel orbiting the vibrant planet of Polaris-9. Life in this quadrant is wonderfully diverse, from the whispering cloud-dwellers of Nebulon to the rhythmic click-speakers of Crustacea Prime.", - "Communication between species used to be seamless, thanks to the Echo Server, a universal translator that instantly echoed your words in the listener's native format.", - "But lately, something's off. Messages are getting scrambled. Some transmissions never arrive. The Echo Server, deployed across the Staging Moonbase and the Production Outpost, is no longer syncing properly. The Argo CD 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", - name: "Broken Echoes", - difficulty: "Beginner", - topics: ["Argo CD"], - learnings: [ - "Debug GitOpsGit Operations flows with Argo CD", - "ApplicationSet templating & pitfalls", - "Environment isolation & namespaces", - "Sync policies: automated, prune & self-heal", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F01-echoes-lost-in-orbit_beginner%2Fdevcontainer.json&quickstart=1`, - discussionUrl: `${COMMUNITY_URL}/t/adventure-01-echoes-lost-in-orbit-easy-broken-echoes/117/40`, - deadline: "2025-12-10T09:00:00+01:00", - intro: [ - "The Echo Server is down across both environments. Investigate the Argo CD ApplicationSet configuration, spot the templating pitfalls, and restore proper multi-environment delivery.", - ], - backstory: [ - "The Echo Server is misbehaving. Both environments seem to be down, and messages are silent.", - "Your mission: investigate the Argo CD configuration and restore proper multi-environment delivery.", - ], - objective: [ - "See two distinct Applications in the Argo CD dashboard (one per environment)", - "Ensure each Application deploys to its own isolated namespace", - "Make the system resilient so Argo CD automatically reverts manual cluster changes", - "Confirm that updates roll out automatically without leaving stale resources behind", - ], - toolbox: [ - { name: "kubectl", description: "Kubernetes CLICommand Line Interface for interacting with the cluster", url: "https://kubernetes.io/docs/reference/kubectl/" }, - { name: "kubens", description: "fast way to switch between Kubernetes namespaces", url: "https://github.com/ahmetb/kubectx" }, - { name: "k9s", description: "terminal UIUser Interface for managing and inspecting your cluster", url: "https://k9scli.io/" }, - ], - howToPlay: [ - { title: "Wait for Infrastructure", content: "

Wait around 5 minutes for the Codespace to provision a Kubernetes cluster, Argo CD, and the sample app. Press Cmd+Shift+P (or Ctrl+Shift+P on Windows/Linux) and search for 'View Creation Log' to track progress.

" }, - { title: "Explore the UIs", content: `

Open the Ports tab and navigate to each service:

-
    -
  • Port 30100: Argo CD (readonly / a-super-secure-password). View application sync status and manage Argo CD resources.
  • -
` }, - { title: "Fix the ApplicationSet", content: `

All errors are in this file:

-
adventures/01-echoes-lost-in-orbit/beginner/manifests/appset.yaml
-
-

This challenge uses Kustomize under the hood: a base set of manifests with environment-specific overlays (staging, prod). Argo CD detects and applies these automatically, so your focus is on fixing the ApplicationSet to properly reference the Kustomize-managed paths.

-

After making changes, apply them:

-
kubectl apply -n argocd -f adventures/01-echoes-lost-in-orbit/beginner/manifests/appset.yaml
-
` }, - { title: "Run the Smoke Test", content: `

Run the smoke test to verify your solution locally:

-
adventures/01-echoes-lost-in-orbit/beginner/smoke-test.sh
-
` }, - ], - 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.", - }, - metaDescription: "Broken Echoes: The Echo Server is down across both environments. Investigate the Argo CD ApplicationSet configuration, spot the templating pitfalls, and...", - }, - { - id: "intermediate", - name: "The Silent Canary", - difficulty: "Intermediate", - topics: ["Argo Rollouts", "PromQL"], - learnings: [ - "Progressive delivery with Argo Rollouts", - "Canary deployments & automated analysis", - "Write PromQL queries for health validation", - "Kube-state-metrics for deployment decisions", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F01-echoes-lost-in-orbit_intermediate%2Fdevcontainer.json&quickstart=1`, - discussionUrl: `${COMMUNITY_URL}/t/adventure-01-echoes-lost-in-orbit-intermediate-the-silent-canary/310/8`, - deadline: "2025-12-24T09:00:00+01:00", - intro: [ - "A canary rollout is stuck and the Zephyrians are still waiting to communicate. Debug the broken progressive delivery system by writing PromQL health checks that let Argo Rollouts automatically validate and advance the deployment.", - ], - backstory: [ - "After fixing the communication outage in Level 1, the Intergalactic Union welcomed a new species: the Zephyrians.", - "The communications team attempted to deploy their language files using a progressive delivery system, but the rollout is failing. The Zephyrians are still waiting to communicate with the rest of the galaxy.", - "A previous engineer configured automated canary deployments with health checks but left the setup incomplete.", - "Your mission: debug the broken rollout and bring the Zephyrians' voices online.", - ], - objective: [ - "Pod info version 6.9.3 deployed successfully in both staging and production environments", - "Rollouts automatically progress through canary stages based on health metrics", - "Two working PromQL queries in the AnalysisTemplate that validate application health during releases", - "All rollouts complete successfully", - ], - toolbox: [ - { name: "kubectl", description: "Kubernetes CLICommand Line Interface for interacting with the cluster", url: "https://kubernetes.io/docs/reference/kubectl/" }, - { name: "kubens", description: "fast way to switch between Kubernetes namespaces", url: "https://github.com/ahmetb/kubectx" }, - { name: "k9s", description: "terminal UIUser Interface for managing and inspecting your cluster", url: "https://k9scli.io/" }, - { name: "Argo CD CLI", description: "manage Argo CD applications from the command line", url: "https://argo-cd.readthedocs.io/en/latest/user-guide/commands/argocd/" }, - { name: "Argo Rollouts kubectl plugin", description: "extended kubectl commands for managing rollouts", url: "https://argo-rollouts.readthedocs.io/en/stable/features/kubectl-plugin/" }, - ], - howToPlay: [ - { title: "Wait for Infrastructure", content: "

Wait ~5-10 minutes for infrastructure to deploy. After it deploys, the setup script starts port forwarding to the Argo Rollouts dashboard, keeping a terminal busy. Open a new terminal to run commands.

" }, - { title: "Explore the UIs", content: `

Open the Ports tab and navigate to each service:

-
    -
  • Port 30100: Argo CD (readonly / a-super-secure-password). Shows sync status. Use to refresh applications after pushing commits.
  • -
  • Port 30101: Argo Rollouts. Shows canary deployment progress and analysis status.
  • -
  • Port 30102: Prometheus. Explore available metrics and test PromQL queries. CLI tools work equally well if you prefer the terminal.
  • -
` }, - { title: "Fix the Manifests", content: `

Review and fix the configuration in adventures/01-echoes-lost-in-orbit/intermediate/manifests/.

-

This challenge uses Kustomize under the hood: a base set of manifests with environment-specific overlays (staging, prod). Argo CD detects and applies these automatically, so you don't need to run Kustomize commands manually.

` }, - { title: "Deploy Your Changes", content: `

Commit and push your changes to trigger the deployment:

-
git add adventures/01-echoes-lost-in-orbit/intermediate/manifests/
-git commit -m "Fix configuration"
-git push
-
-

If pushing to a branch other than main, also update the ApplicationSet in appset.yaml to point to your branch.

-

Speed up Argo CD sync:

-
argocd app get echo-server-staging --refresh
-argocd app get echo-server-prod --refresh
-
` }, - { title: "Trigger the Rollout", content: `

After Argo CD syncs, retry the rollouts:

-
kubectl argo rollouts retry rollout echo-server -n echo-staging
-kubectl argo rollouts retry rollout echo-server -n echo-prod
-
` }, - { title: "Watch the Rollout", content: `

Watch canary progress (should advance 33% to 66% to 100%):

-
kubectl argo rollouts get rollout echo-server -n echo-staging --watch
-kubectl argo rollouts get rollout echo-server -n echo-prod --watch
-
-

In real-world progressive delivery, staging is updated first, validated, and then changes are promoted to production. This challenge skips that separation so you can focus on the canary rollout mechanics and health checks without managing two promotion steps.

` }, - { title: "Run the Smoke Test", content: `

Run the smoke test to verify your solution:

-
adventures/01-echoes-lost-in-orbit/intermediate/smoke-test.sh
-
` }, - ], - helpfulLinks: [ - { title: "Argo Rollouts documentation", url: "https://argo-rollouts.readthedocs.io/en/stable/" }, - { title: "Analysis and progressive delivery", url: "https://argo-rollouts.readthedocs.io/en/stable/features/analysis/" }, - { title: "PromQL basics", url: "https://prometheus.io/docs/prometheus/latest/querying/basics/" }, - { title: "kube-state-metrics exposed metrics", url: "https://github.com/kubernetes/kube-state-metrics/tree/main/docs#exposed-metrics" }, - ], - 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.", - }, - metaDescription: "The Silent Canary: A canary rollout is stuck and the Zephyrians are still waiting to communicate. Debug the broken progressive delivery system by writing...", - }, - { - id: "expert", - name: "Hyperspace Operations & Transport", - difficulty: "Expert", - topics: ["Argo Rollouts", "OpenTelemetry", "Jaeger", "PromQL"], - learnings: [ - "Configure OpenTelemetry Collector pipelines", - "Spanmetrics connector (traces to metrics)", - "Detect idle canaries with traffic validation", - "Distributed tracing with Jaeger", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F01-echoes-lost-in-orbit_expert%2Fdevcontainer.json&quickstart=1`, - discussionUrl: `${COMMUNITY_URL}/t/adventure-01-echoes-lost-in-orbit-expert-hyperspace-operations-transport/351/4`, - deadline: "2026-01-14T09:00:00+01:00", - intro: [ - "The observability pipeline is broken and HotROD's canary can't validate. Wire an OpenTelemetry Collector with spanmetrics to convert distributed traces into Prometheus metrics, then write PromQL queries that catch idle canaries, high error rates, and latency spikes.", - ], - backstory: [ - "After fixing the Zephyrian communications, word of your progressive release mastery spread across the galaxy. The Bytari, a highly advanced species from the Andromeda sector, were impressed.", - "They want to apply progressive delivery to their mission-critical service: HotROD (Hyperspace Operations & Transport, Rapid Orbital Dispatch), an interstellar ride-sharing service handling dispatch requests across thousands of star systems. Every millisecond of latency matters, and any error could strand travelers between dimensions.", - "A previous engineer started instrumenting HotROD with OpenTelemetry and configured Argo Rollouts for automated validation, but left the setup incomplete. The observability pipeline is broken. The Bytari don't use staging/production environments; they believe in single-environment progressive delivery validated purely by trace-derived metrics and automated health checks.", - "Your mission: fix the observability pipeline and canary validation. Make HotROD deployment-ready with proper distributed tracing.", - ], - objective: [ - "Automated rollout progression to HotROD version 1.76.0 driven by observability signals", - "OpenTelemetry Collector configured with an OTLPOpenTelemetry Protocol receiver for HotROD traces, a Spanmetrics connector converting traces to metrics, trace export to Jaeger, and metrics export to Prometheus", - "Canary analysis with three PromQL queries: traffic detection (at least 0.05 req/s to prevent idle canaries), error rate below 5%, and 95th-percentile latency below 1000ms", - ], - toolbox: [ - { name: "kubectl", description: "Kubernetes CLICommand Line Interface for interacting with the cluster", url: "https://kubernetes.io/docs/reference/kubectl/" }, - { name: "kubens", description: "fast way to switch between Kubernetes namespaces", url: "https://github.com/ahmetb/kubectx" }, - { name: "k9s", description: "terminal UIUser Interface for managing and inspecting your cluster", url: "https://k9scli.io/" }, - { name: "Argo CD CLI", description: "manage Argo CD applications from the command line", url: "https://argo-cd.readthedocs.io/en/latest/user-guide/commands/argocd/" }, - { name: "Argo Rollouts kubectl plugin", description: "extended kubectl commands for managing rollouts", url: "https://argo-rollouts.readthedocs.io/en/stable/features/kubectl-plugin/" }, - ], - howToPlay: [ - { title: "Wait for Infrastructure", content: "

Wait ~5-10 minutes for infrastructure to deploy. Port forwarding starts automatically after infrastructure is ready, keeping a terminal busy. Open a new terminal to run commands.

" }, - { title: "Explore the UIs", content: `

Open the Ports tab and navigate to each service:

-
    -
  • Port 30100: Argo CD (readonly / a-super-secure-password). Shows sync status. Use to refresh applications after pushing commits.
  • -
  • Port 30101: Argo Rollouts. Shows canary deployment progress and analysis status.
  • -
  • Port 30102: Prometheus. Explore available metrics and test PromQL queries. CLI tools work equally well if you prefer the terminal.
  • -
  • Port 30103: Jaeger. Shows distributed traces from HotROD to verify that tracing is working end-to-end.
  • -
` }, - { title: "Fix the Manifests", content: "

Fix the manifests in adventures/01-echoes-lost-in-orbit/expert/manifests/. Use the Argo Rollouts dashboard, Prometheus UI, and Jaeger UI to debug and validate your changes.

" }, - { title: "Deploy Your Changes", content: `

Commit and push to trigger the deployment:

-
git add adventures/01-echoes-lost-in-orbit/expert/manifests/
-git commit -m "Fix configuration"
-git push
-
-

If pushing to a branch other than main, also update the ApplicationSet in appset.yaml to point to your branch.

-

Refresh Argo CD apps:

-
argocd app get hotrod --refresh
-argocd app get otel --refresh
-
-

If you changed HotROD, retry the rollout:

-
kubectl argo rollouts retry rollout hotrod -n hotrod
-
-

If you changed the OTelOpenTelemetry Collector config, restart it:

-
kubectl rollout restart daemonset/collector -n otel
-
` }, - { title: "Watch the Rollout", content: `

Watch rollout progress. The rollout should progress automatically based on analysis metrics:

-
kubectl argo rollouts get rollout hotrod -n hotrod --watch
-
` }, - { title: "Run the Smoke Test", content: `

Run the smoke test to verify your solution:

-
adventures/01-echoes-lost-in-orbit/expert/smoke-test.sh
-
` }, - ], - helpfulLinks: [ - { title: "OpenTelemetry Collector configuration", url: "https://opentelemetry.io/docs/collector/configuration/" }, - { title: "Span Metrics Connector", url: "https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/connector/spanmetricsconnector" }, - { title: "Argo Rollouts analysis", url: "https://argo-rollouts.readthedocs.io/en/stable/features/analysis/" }, - { title: "PromQL basics", url: "https://prometheus.io/docs/prometheus/latest/querying/basics/" }, - ], - verification: { - command: "adventures/01-echoes-lost-in-orbit/expert/smoke-test.sh", - description: "Once you think you've solved the challenge, run the smoke test to verify your solution.", - }, - metaDescription: "Hyperspace Operations & Transport: The observability pipeline is broken and HotROD's canary can't validate. Wire an OpenTelemetry Collector with spanmetrics...", - }, - ], -}; diff --git a/src/data/adventures/filter-utils.ts b/src/data/adventures/filter-utils.ts deleted file mode 100644 index 8b968e0ec..000000000 --- a/src/data/adventures/filter-utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ADVENTURE_SUMMARIES } from "./summaries"; -import type { RelatedLevelSummary } from "./types"; - -export const DIFFICULTIES = ["Beginner", "Intermediate", "Expert"] as const; -export type Difficulty = (typeof DIFFICULTIES)[number]; - -/** Returns level summaries matching any selected tag (OR) and/or a difficulty. */ -export const getLevelSummariesByFilters = ( - tags: string[], - difficulty: string | null -): RelatedLevelSummary[] => - ADVENTURE_SUMMARIES - .filter((a) => tags.length === 0 || tags.some((t) => a.tags.includes(t))) - .flatMap((a) => - a.levels - .filter((level) => !difficulty || level.difficulty === difficulty) - .map((level) => ({ - level, - adventureId: a.id, - adventureTitle: a.title, - ...(a.isLive ? { isLive: true as const } : {}), - ...(a.icon ? { adventureIcon: a.icon } : {}), - })) - ); - -export const ALL_LEVEL_SUMMARIES: RelatedLevelSummary[] = getLevelSummariesByFilters([], null); diff --git a/src/data/adventures/index.ts b/src/data/adventures/index.ts deleted file mode 100644 index 833d0c8ff..000000000 --- a/src/data/adventures/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { DEAD_RECKONING } from "./dead-reckoning.generated"; -import { LEX_IMPERFECTA } from "./lex-imperfecta.generated"; -import { BLIND_BY_DESIGN } from "./blind-by-design.generated"; -import { THE_AI_OBSERVATORY } from "./the-ai-observatory.generated"; -import { BUILDING_CLOUDHAVEN } from "./building-cloudhaven.generated"; -import { ECHOES_LOST_IN_ORBIT } from "./echoes-lost-in-orbit.generated"; -import type { Adventure, AdventureContributor, RelatedLevel } from "./types"; - -export type { Adventure, AdventureLevel, AdventureContributor, RelatedLevel, ToolboxItem, WalkthroughStep, VerificationInfo, TopPlayer, UpcomingLevel, AdventureLevelSummary, AdventureCardSummary, RelatedLevelSummary } from "./types"; - -export const ADVENTURES: Adventure[] = [ - DEAD_RECKONING, - LEX_IMPERFECTA, - BLIND_BY_DESIGN, - THE_AI_OBSERVATORY, - BUILDING_CLOUDHAVEN, - ECHOES_LOST_IN_ORBIT, -]; - -/** All unique technology tags across all adventures, sorted alphabetically. Shared with filter components; do not re-derive in component files. */ -export const ALL_TAGS: string[] = Array.from( - new Set(ADVENTURES.flatMap((a) => a.tags)) -).sort(); - -/** Community members who contributed an adventure, grouped by person. Derived from ADVENTURES; do not re-derive in components. */ -export const ADVENTURE_CONTRIBUTORS: AdventureContributor[] = Object.values( - ADVENTURES - .filter((a): a is Adventure & { contributor: NonNullable } => a.contributor !== undefined) - .reduce>((acc, a) => { - const key = a.contributor.name; - if (!acc[key]) { - acc[key] = { name: a.contributor.name, url: a.contributor.url, aboutHtml: a.contributor.aboutHtml, adventures: [] }; - } - acc[key].adventures.push({ id: a.id, title: a.title }); - return acc; - }, {}) -); - -/** Returns all levels across all adventures that include the given technology tag. */ -export const getLevelsByTag = (tag: string): RelatedLevel[] => - ADVENTURES.filter((adventure) => adventure.tags.includes(tag)).flatMap((adventure) => - adventure.levels.map((level) => ({ - level, - adventureId: adventure.id, - adventureTitle: adventure.title, - })) - ); - -export { tagToSlug, slugToTag } from "./tag-utils"; \ No newline at end of file diff --git a/src/data/adventures/lex-imperfecta.generated.ts b/src/data/adventures/lex-imperfecta.generated.ts deleted file mode 100644 index a0c187dc7..000000000 --- a/src/data/adventures/lex-imperfecta.generated.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { CODESPACES_BASE, COMMUNITY_URL } from "@/data/constants"; -import lexImperfectaBeginner from "@/assets/diagrams/lex-imperfecta-beginner.svg"; -import type { Adventure } from "./types"; - -export const LEX_IMPERFECTA: Adventure = { - id: "lex-imperfecta", - title: "Lex Imperfecta", - icon: "Scale", - month: "JUN 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.", - metaDescription: "The Republic's legal system is in disarray — workloads run unchecked, required labels go missing, and privileged containers slip through the gates. As a...", - tags: ["Kyverno", "Policy Reporter", "Kubernetes"], - contributor: { - name: "Katharina Sick", - url: "https://ksick.dev/", - aboutHtml: "DevRelDeveloper Relations at Dynatrace and co-organizer of Cloud Native Linz. Passionate about building user-friendly Cloud Native and Kubernetes solutions, with a background in mobile and backend development. Found in tech and sports communities, inline skating rinks, and quiz nights across Europe.", - }, - 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: "2026-06-30T23:59:00+01:00", - 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, SREsSite Reliability Engineers, and developers curious about Kubernetes security — no prior Kyverno experience needed, but familiarity with basic kubectl and YAML will help.", - learnings: [ - "How Kyverno ValidatingPolicy resources and CELCommon Expression Language validation expressions work", - "The difference between Audit, Deny, and Warn validation actions", - "How to use custom label keys to enforce workload identity standards", - "How Kyverno MutatingPolicy resources automatically patch incoming workloads at admission", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F05-lex-imperfecta_01-beginner%2Fdevcontainer.json&quickstart=1`, - discussionUrl: "https://community.offon.dev/t/restore-proper-admission-control-using-kyverno-june-2026-adventure-beginner/1576", - deadline: "2026-06-30T23:59:00+01:00", - 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 are blocked at admission with a clear policy violation message", - "All workloads running as privileged containers are blocked at admission with a clear policy violation message", - "All pods declaring republic.rome/traveler: peregrinus automatically receive the republic.rome/travel-permit: granted label", - "All other workloads deploy and run successfully in the cluster", - ], - architecture: [ - "

The Twelve Tables enforced Roman law at the gates — before a citizen could act, not after the damage was done. Kyverno works the same way: it intercepts every workload request before it reaches the cluster. A misconfigured policy doesn't just fail to enforce — it fails silently, letting non-compliant workloads slip through while you assume everything is fine.

", - "

Your Codespace comes with a Kubernetes cluster and Kyverno pre-installed. Three broken policies are already deployed in manifests/policies/ — two ValidatingPolicy resources and one MutatingPolicy. Edit them directly and re-apply with kubectl. The pods in manifests/pods/ are for reference only — no GitOpsGit Operations, no dashboards.

", - ], - architectureDiagram: lexImperfectaBeginner, - diagramAlt: "Workload request flows through Kyverno's admission webhook before reaching the Kubernetes cluster. Two ValidatingPolicy resources block non-compliant workloads, and one MutatingPolicy automatically patches admitted workloads with required labels.", - 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 UIUser Interface", 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:

-
kubectl get pods
-
-

Inspect why a pod was blocked or admitted:

-
kubectl describe pod <pod-name>
-
-

Check the policies that are in place:

-
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:

-
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 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 CLICommand Line Interface

-

Before applying to the cluster, you can use the kyverno CLI to test your policy changes locally against the workload manifests:

-
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:

-
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.", - }, - metaDescription: "The Twelve Tables: Fix broken Kyverno policies to restore proper admission control. A beginner Kyverno, Kubernetes challenge on OffOn.", - }, - { - id: "intermediate", - name: "Governing the Provinces", - difficulty: "Intermediate", - topics: ["Kyverno", "Policy Reporter", "Kubernetes"], - audience: "Platform engineers and SREsSite Reliability Engineers who have some familiarity with Kyverno, ideally after completing the Beginner level. You should be comfortable reading Kubernetes YAML and basic kubectl commands.", - learnings: [ - "How to scope policies using ValidatingPolicy (cluster-wide) and NamespacedValidatingPolicy (per-namespace), and when to use each", - "How CEL expressions in ValidatingPolicy and PolicyException express fine-grained admission conditions", - "How to write and scope a PolicyException correctly so only the intended workloads are exempt", - "How to use Policy Reporter and the OpenReports format to audit and debug a policy estate across multiple namespaces", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F05-lex-imperfecta_02-intermediate%2Fdevcontainer.json&quickstart=1`, - discussionUrl: "https://community.offon.dev/t/fix-a-broken-kyverno-policy-estate-june-2026-adventure-intermediate/1581", - deadline: "2026-06-30T23:59:00+01:00", - intro: [ - "Fix a misconfigured Kyverno policy estate and use Policy Reporter to restore proper governance across the Republic's provinces.", - ], - backstory: [ - "The Republic has grown. What once was a single city is now a sprawling empire of provinces, each governed by different magistrates with different needs. The legal scholars decided to catalogue every law in a central archive (the Tabularium) so that each province's statutes could be tracked and audited in one place.", - "But cataloguing the laws introduced new chaos. Policies meant for one province are bleeding into another. Exceptions that were meant to be narrow have been written too broadly. And somewhere in the estate, a workload is slipping through that shouldn't be.", - "The Tabularium's auditors have handed you a report: Policy Reporter shows violations where there should be none, and silence where there should be enforcement. Your mission: investigate the policy estate, fix the scoping issues, and restore order before the provinces descend into chaos.", - ], - objective: [ - "Empire-wide laws enforce across every province: no privileged containers, every workload carries a valid republic.rome/gens and republic.rome/province matching its namespace, scoped by namespace label rather than hardcoded names", - "Aegyptus's scribe law applies only within Aegyptus, admitting republic.rome/role: scribe workloads exclusively", - "The legacy exception is scoped to Aegyptus's grandfathered workload and cannot be claimed by any other province", - "The Tabularium's ledger is on file: policy reports exported in OpenReports format as estate-audit.yaml", - ], - architecture: [ - "

Five namespaces span the estate: four provinces (gallia, hispania, britannia, aegyptus) with republic.rome/realm: province, and castra, the infra namespace, with republic.rome/realm: infra. These labels drive policy scoping; use kubectl get ns --show-labels to inspect them.

", - "

Two empire-wide policies cover all provinces: no-privileged-containers and require-census (every workload must declare a valid republic.rome/gens and a matching republic.rome/province). Aegyptus adds aegyptus-require-scribe-role for its local scribe requirement, and a PolicyException covers its single legacy workload.

", - "

The broken policies live in manifests/policies/ and manifests/exceptions/. After each change, run make apply to redeploy the workloads, then make verify to check your progress.

", - ], - toolbox: [ - { name: "kubectl", description: "Apply and inspect cluster resources, check namespace labels and policy status", url: "https://kubernetes.io/docs/reference/kubectl/" }, - { name: "kyverno CLI", description: "Test and lint policies locally before applying to the cluster", url: "https://kyverno.io/docs/kyverno-cli/" }, - { name: "k9s", description: "Explore cluster resources and policy reports in a terminal UIUser Interface", url: "https://k9scli.io/" }, - ], - howToPlay: [ - { title: "Explore the Estate", content: `

When your Codespace is ready, the policy estate is already deployed, but something is wrong. -Open Policy Reporter at port 30110 (find it in the Ports tab) to get an overview of the estate:

-
    -
  • Which namespaces have violations?
  • -
  • Which policies are generating results, and which are silent when they shouldn't be?
  • -
-

Then dig into the cluster:

-
# Inspect the namespace topology — the labels here drive policy scoping
-kubectl get ns --show-labels
-
-# List all policies — note which are cluster-wide and which are namespaced
-kubectl get validatingpolicies
-kubectl get namespacedvalidatingpolicies -A
-
-# Inspect any policy or exception in full
-kubectl get validatingpolicy <name> -o yaml
-kubectl get policyexceptions -A -o yaml
-
-# See the raw OpenReports data behind Policy Reporter
-kubectl get policyreports -A
-
-

You can also launch k9s for a terminal UI view:

-
k9s
-
` }, - { title: "Fix the Policies", content: `

Review the Objective and investigate what is wrong in manifests/policies/ and -manifests/exceptions/.

-

Think about what each policy is supposed to cover, and compare that against what it is actually -matching. The namespace labels you saw with kubectl get ns --show-labels are a key part of the picture.

-

Test locally with the Kyverno CLICommand Line Interface before applying:

-
kyverno apply manifests/policies/require-census.yaml --resource manifests/workloads/citizens.yaml
-kyverno apply manifests/policies/aegyptus-require-scribe-role.yaml --resource manifests/workloads/aegyptus-legacy-scribe.yaml
-
-

Apply your changes to the cluster:

-
make apply
-
-

Policies only act at admission, so make apply redeploys the workloads to re-evaluate the estate against -your changes. Then check Policy Reporter again. The picture should improve as you fix each issue.

` }, - { title: "File the Audit", content: `

Once the estate is in order, the Senate expects the Tabularium's ledger on file. Export the cluster's -policy reports, the OpenReports data behind Policy Reporter, as the audit of record.

-
kubectl get policyreports -A -o yaml > estate-audit.yaml
-
` }, - ], - helpfulLinks: [ - { title: "Kyverno ValidatingPolicy", url: "https://kyverno.io/docs/policy-types/validating-policy/", description: "Reference docs for ValidatingPolicy and NamespacedValidatingPolicy: the policy types you'll fix" }, - { title: "Kyverno PolicyException", url: "https://kyverno.io/docs/guides/exceptions/", description: "How to write and scope a PolicyException to exempt specific workloads" }, - { title: "CEL Validation Expressions", url: "https://kubernetes.io/docs/reference/using-api/cel/", description: "How CEL expressions work in Kubernetes admission, including accessing namespace context" }, - { title: "Policy Reporter", url: "https://kyverno.github.io/policy-reporter/", description: "How to use Policy Reporter to audit and visualise policy results across the cluster" }, - { title: "OpenReports Format", url: "https://openreports.io/", description: "The OpenReports standard that Kyverno uses to emit PolicyReport resources" }, - ], - 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.", - }, - metaDescription: "Governing the Provinces: Fix a misconfigured Kyverno policy estate and use Policy Reporter to restore proper governance across the Republic's provinces.", - }, - { - id: "expert", - name: "Quis Custodiet", - difficulty: "Expert", - topics: ["Kyverno", "Policy Reporter", "Kubernetes"], - audience: "Security engineers and platform engineers who want to explore the boundary between admission control and runtime security. Completing the Intermediate level first is helpful but not required. You should be comfortable reading Kyverno ValidatingPolicies and CELCommon Expression Language expressions. No prior Falco experience required.", - learnings: [ - "How Falco rules are structured: conditions, output, and kernel-level fields, and how to write a rule targeting a specific runtime behaviour", - "Why privileged: false is not enough: how Linux capabilities grant host-level access without the privileged flag", - "How to use spec.variables in a ValidatingPolicy to share reusable CEL expressions across validations", - "How pod volumes reference secrets, and why a volume's name and the secret it mounts are two separate fields in the pod spec", - "How Falcosidekick aggregates Falco alerts and how to use its UI to watch a runtime incident in real time", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F05-lex-imperfecta_03-expert%2Fdevcontainer.json&quickstart=1`, - discussionUrl: "https://community.offon.dev/t/catch-the-intruder-the-guard-couldnt-see-june-2026-adventure-expert/1591", - deadline: "2026-06-30T23:59:00+01:00", - intro: [ - "An intruder is already inside the Republic, and the watchmen cannot see it. Fix the Praetorian Guard's broken detection rule, close the admission gap that let the intruder slip through, and seal the census archive against unauthorized access.", - ], - backstory: [ - "The Republic's defences have always rested on the law: block the wrong workloads at the gate, and nothing bad gets in. But the Senate's Praetorian Guard was built for a different threat: the workload that slips through and acts badly at runtime. The Guard watches the provinces through Falco, but tonight, the watchtower is dark. Someone broke the rule that should fire when the census archive is touched. The Guard sees nothing.", - "And while the Guard slept, an intruder crept in. It declared valid labels, passed the census, and presented itself as a loyal citizen of the Republic. Its papers were in order. Its power was not. Once inside, it reached straight for the census archive: the imperial rolls of every citizen, sealed records it had no right to touch. It reads them on a loop and tries to send them out of the Republic.", - ], - objective: [ - "The Praetorian Guard awake: Falco fires an alert every time an unauthorized process reads the census archive, with live alerts streaming into the Falcosidekick UIUser Interface", - "The gate closed: the intruder is denied re-admission. The policy that kept privileged containers out now covers every path to unchecked host power", - "The archive sealed: the census-archive secret is inaccessible to any workload that does not bear the Archivist role", - "The empire-wide laws holding: all intermediate-level checks still green across every province", - ], - architecture: [ - "

The estate inherits the full intermediate topology: four province namespaces (gallia, hispania, britannia, aegyptus) and one infra namespace (castra), each labelled as before. Alongside the Kyverno stack and Policy Reporter, the cluster now runs Falco (eBPFextended Berkeley Packet Filter-based, as a DaemonSet) and Falcosidekick with its UI at port 30111. At startup, an intruder pod is already running in one of the provinces, quietly reading the census archive: imperial rolls that only workloads bearing the republic.rome/role: archivist label are permitted to access.

", - "

Your working directory is the challenge root. manifests/secrets/ and manifests/workloads/ are already in place. They define the estate and the intruder, and need no changes. Everything else is yours to investigate and fix.

", - ], - toolbox: [ - { name: "kubectl", description: "Apply and inspect cluster resources, check pod status and security contexts", url: "https://kubernetes.io/docs/reference/kubectl/" }, - { name: "k9s", description: "Explore cluster resources, pod logs, and policy reports in a terminal UI", url: "https://k9scli.io/" }, - { name: "kyverno", description: "Test a policy against a resource locally before applying it to the cluster", url: "https://kyverno.io/docs/kyverno-cli/" }, - ], - howToPlay: [ - { title: "Survey the Scene", content: `

When your Codespace opens, the intruder is already running. Open the Falcosidekick UI at -the forwarded port 30111. It should be streaming alerts about census archive reads, -but it is silent. That silence is your first clue that something is wrong with the Guard.

-

Start by getting oriented:

-
# Can you find the intruder?
-kubectl get pods -A
-
-# Read the Falco rule that should be firing
-cat falco-rules.yaml
-
-# Which policies are in force?
-kubectl get validatingpolicies
-kubectl get namespacedvalidatingpolicies -A
-
-

Open Policy Reporter at port 30110 as well. The intermediate estate should look clean: -the intruder left no trace at admission. That is part of the problem.

` }, - { title: "Act 1: Wake the Praetorian Guard", content: `

The Falco rule in falco-rules.yaml has a defect: find the break, fix it, and run make apply; -alerts streaming into the Falcosidekick UI at port 30111 are your signal. The -Falco condition fields reference -documents every available field.

` }, - { title: "Act 2: Close the Gate", content: `

The intruder passed admission: find the policy gap that let it through, close it, and run -make apply; re-admission denied and the Falcosidekick UI going quiet confirm Act 2 is done. -The existing policy already uses spec.variables -to share expressions across validations, a pattern worth exploring.

` }, - { title: "Act 3: Seal the Archive", content: `

Open manifests/policies/: something is missing; the other policies show the structure, write -what's needed, then run make apply.

-

Going further: even with the archive sealed at admission, any workload admitted with the -Archivist role can read the secret. Kubernetes RBACRole-Based Access Control can restrict which service accounts may -get a secret at the APIApplication Programming Interface level, a complementary layer that admission control alone cannot -provide.

` }, - ], - helpfulLinks: [ - { title: "Falco Rules Reference", url: "https://falco.org/docs/reference/rules/", description: "The anatomy of a Falco rule: condition, output, priority, tags, and how rules are evaluated" }, - { title: "Falco Condition Fields", url: "https://falco.org/docs/reference/rules/supported-fields/", description: "Every field available in Falco rule conditions: syscall events, file descriptors, process info, and Kubernetes metadata" }, - { title: "Linux Capabilities in Kubernetes", url: "https://kubernetes.io/docs/tasks/configure-pod-container/security-context/#set-capabilities-for-a-container", description: "How Linux capabilities work and how to configure or restrict them in a pod's security context" }, - { title: "Kyverno ValidatingPolicy", url: "https://kyverno.io/docs/policy-types/validating-policy/", description: "Reference docs for ValidatingPolicy, including spec.variables for composing reusable CEL expressions" }, - { title: "CEL Validation Expressions", url: "https://kubernetes.io/docs/reference/using-api/cel/", description: "How CEL expressions work in Kubernetes admission: operators, optional chaining, and collection functions" }, - { title: "Falcosidekick", url: "https://github.com/falcosecurity/falcosidekick", description: "The Falco alert aggregator that routes Falco events to sinks including the Falcosidekick UI" }, - ], - 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.", - }, - metaDescription: "Quis Custodiet: An intruder is already inside the Republic, and the watchmen cannot see it. Fix the Praetorian Guard's broken detection rule, close the...", - }, - ], -}; diff --git a/src/data/adventures/lex-imperfecta/adventure.yaml b/src/data/adventures/lex-imperfecta/adventure.yaml index 4e56e3a6b..ba3e7cbbd 100644 --- a/src/data/adventures/lex-imperfecta/adventure.yaml +++ b/src/data/adventures/lex-imperfecta/adventure.yaml @@ -8,12 +8,12 @@ tags: - Policy Reporter - Kubernetes backstory: - - The Roman Republic has built a sophisticated legal system to protect its citizens — but the laws were written in + - 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 + - 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: @@ -37,15 +37,15 @@ levels: community_url: https://community.offon.dev/t/restore-proper-admission-control-using-kyverno-june-2026-adventure-beginner/1576 summary: Fix broken Kyverno policies to restore proper admission control. audience: >- - Platform engineers, SREs, and developers curious about Kubernetes security — no prior Kyverno experience + Platform engineers, SREs, and developers curious about Kubernetes security - no prior Kyverno experience needed, but familiarity with basic `kubectl` and YAML will help. backstory: - - The Republic's legal scholars have been busy — perhaps too busy. In their haste to codify the Twelve Tables, + - 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 + 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." @@ -66,13 +66,13 @@ levels: - How Kyverno [MutatingPolicy](https://kyverno.io/docs/policy-types/mutating-policy/) resources automatically patch incoming workloads at admission architecture: - - "The Twelve Tables enforced Roman law **at the gates** — before a citizen could act, not after the damage was + - "The Twelve Tables enforced Roman law **at the gates** - before a citizen could act, not after the damage was done. Kyverno works the same way: it intercepts every workload request *before* it reaches the cluster. A - misconfigured policy doesn't just fail to enforce — it fails silently, letting non-compliant workloads slip + misconfigured policy doesn't just fail to enforce - it fails silently, letting non-compliant workloads slip through while you assume everything is fine." - Your Codespace comes with a Kubernetes cluster and Kyverno pre-installed. Three broken policies are already - deployed in `manifests/policies/` — two `ValidatingPolicy` resources and one `MutatingPolicy`. Edit them - directly and re-apply with `kubectl`. The pods in `manifests/pods/` are for reference only — no GitOps, no + deployed in `manifests/policies/` - two `ValidatingPolicy` resources and one `MutatingPolicy`. Edit them + directly and re-apply with `kubectl`. The pods in `manifests/pods/` are for reference only - no GitOps, no dashboards. toolbox: - name: kubectl @@ -89,7 +89,7 @@ levels: - id: explore 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 + When your Codespace is ready, four pods are already running - or trying to. Open a terminal and check what's going on: @@ -147,7 +147,7 @@ levels: 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 + All three broken policies are in `manifests/policies/`. Read them carefully - each has a different kind of misconfiguration. @@ -189,13 +189,13 @@ levels: helpful_links: - 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 + 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 + 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 + 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 @@ -283,10 +283,10 @@ levels: Then dig into the cluster: ```bash - # Inspect the namespace topology — the labels here drive policy scoping + # Inspect the namespace topology - the labels here drive policy scoping kubectl get ns --show-labels - # List all policies — note which are cluster-wide and which are namespaced + # List all policies - note which are cluster-wide and which are namespaced kubectl get validatingpolicies kubectl get namespacedvalidatingpolicies -A diff --git a/src/data/adventures/summaries.ts b/src/data/adventures/summaries.ts deleted file mode 100644 index e0ccbbcf4..000000000 --- a/src/data/adventures/summaries.ts +++ /dev/null @@ -1,343 +0,0 @@ -// Generated by scripts/generate-adventures.mjs — do not edit by hand. -import type { AdventureCardSummary, AdventureContributor, RelatedLevelSummary } from "./types"; - -export const ADVENTURE_SUMMARIES: AdventureCardSummary[] = [ - { - id: "dead-reckoning", - title: "Dead Reckoning", - month: "JUL 2026", - story: "The Grand Fleet's commission office is buried in complaints. Manifests are filed but nothing comes of them. Vessels that do sail arrive at port with the wrong cargo, and no one along the route can explain why. As the fleet's engineer, your mission is to restore order from keel to quayside and find out what the records are hiding.", - tags: ["Backstage", "Gitea", "Argo Events", "Argo Workflows", "Argo CD"], - contributor: { - name: "Katharina Sick", - url: "https://ksick.dev/", - aboutHtml: "DevRelDeveloper Relations at Dynatrace and co-organizer of Cloud Native Linz. Passionate about building user-friendly Cloud Native and Kubernetes solutions, with a background in mobile and backend development. Found in tech and sports communities, inline skating rinks, and quiz nights across Europe.", - }, - isLive: true, - icon: "Compass", - levels: [ - { - id: "beginner", - name: "Laying the Keel", - difficulty: "Beginner", - topics: ["Backstage", "Gitea"], - learnings: [ - "How Backstage software templates are structured: parameters, steps, and output", - "How scaffolder actions work, such as fetch:template, publish:gitea, and catalog:register", - "How the catalog registration step connects a scaffolded repository to the Backstage catalog", - "How to use Backstage's built-in template tooling: the installed-actions browser and the Template Editor's live preview and dry-run", - ], - }, - { - id: "intermediate", - name: "Sea Trial", - difficulty: "Intermediate", - topics: ["Backstage", "Gitea", "Argo Events", "Argo Workflows", "Argo CD"], - learnings: [ - "How a Git webhook drives a workflow engine: Argo Events Sensors turn a push into a parameterized workflow run", - "How Argo Workflows runs a multi-step delivery pipeline, and the RBAC its steps need", - "How an Argo CD ApplicationSet auto-discovers repos and syncs them into the cluster", - "How Backstage annotations tie a catalog entity to its live deployment status", - "How to trace a silent failure across tools from each one's logs and UI", - ], - }, - { - id: "expert", - name: "The Chronometer", - difficulty: "Expert", - topics: ["Backstage", "Argo Workflows", "Argo CD", "OpenTelemetry", "Jaeger"], - learnings: [ - "How trace context crosses an asynchronous boundary with no call chain: a commission carries its W3C traceparent to a push-triggered pipeline, so its spans continue the same trace instead of starting a new one", - "Why a distributed trace reveals what per-tool logs cannot: the value that flowed through each step, so a fault surfaces where the data diverges", - "How to read that trace in Jaeger to localise a fault to a single service by following one attribute across the whole voyage", - ], - }, - ], - }, - { - id: "lex-imperfecta", - title: "Lex Imperfecta", - month: "JUN 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", "Policy Reporter", "Kubernetes"], - contributor: { - name: "Katharina Sick", - url: "https://ksick.dev/", - aboutHtml: "DevRelDeveloper Relations at Dynatrace and co-organizer of Cloud Native Linz. Passionate about building user-friendly Cloud Native and Kubernetes solutions, with a background in mobile and backend development. Found in tech and sports communities, inline skating rinks, and quiz nights across Europe.", - }, - icon: "Scale", - levels: [ - { - id: "beginner", - name: "The Twelve Tables", - difficulty: "Beginner", - topics: ["Kyverno", "Kubernetes"], - learnings: [ - "How Kyverno ValidatingPolicy resources and CELCommon Expression Language validation expressions work", - "The difference between Audit, Deny, and Warn validation actions", - "How to use custom label keys to enforce workload identity standards", - "How Kyverno MutatingPolicy resources automatically patch incoming workloads at admission", - ], - }, - { - id: "intermediate", - name: "Governing the Provinces", - difficulty: "Intermediate", - topics: ["Kyverno", "Policy Reporter", "Kubernetes"], - learnings: [ - "How to scope policies using ValidatingPolicy (cluster-wide) and NamespacedValidatingPolicy (per-namespace), and when to use each", - "How CEL expressions in ValidatingPolicy and PolicyException express fine-grained admission conditions", - "How to write and scope a PolicyException correctly so only the intended workloads are exempt", - "How to use Policy Reporter and the OpenReports format to audit and debug a policy estate across multiple namespaces", - ], - }, - { - id: "expert", - name: "Quis Custodiet", - difficulty: "Expert", - topics: ["Kyverno", "Policy Reporter", "Kubernetes"], - learnings: [ - "How Falco rules are structured: conditions, output, and kernel-level fields, and how to write a rule targeting a specific runtime behaviour", - "Why privileged: false is not enough: how Linux capabilities grant host-level access without the privileged flag", - "How to use spec.variables in a ValidatingPolicy to share reusable CEL expressions across validations", - "How pod volumes reference secrets, and why a volume's name and the secret it mounts are two separate fields in the pod spec", - "How Falcosidekick aggregates Falco alerts and how to use its UI to watch a runtime incident in real time", - ], - }, - ], - }, - { - id: "blind-by-design", - title: "Blind by Design", - month: "MAY 2026", - 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 (Intermediate), then instrument flag evaluations with OpenTelemetry and roll back a misbehaving fractional rollout (Expert). All without redeploying.", - tags: ["OpenFeature", "flagd", "Spring Boot", "Java", "OpenTelemetry", "Grafana"], - contributor: { - name: "Simon Schrottner", - url: "https://schrottner.at/", - aboutHtml: "CNCFCloud Native Computing Foundation Ambassador and maintainer of OpenFeature and JUnit Pioneer. Helps teams release faster and with more confidence through open standards, feature flagging, and the communities that make both possible. A familiar face at KubeCon EU, Devoxx, ContainerDays, and meetups across Europe.", - }, - icon: "FlaskConical", - levels: [ - { - id: "beginner", - name: "Stand up the Lab", - difficulty: "Beginner", - topics: ["OpenFeature", "flagd", "Spring Boot"], - learnings: [ - "How an OpenFeature client and provider work together: the SDKSoftware Development Kit is provider-agnostic and the flagd provider plugs in via dependency only", - "What remote provider means in practice: the SDK calls a separate flag service (flagd) over gRPCGoogle Remote Procedure Call, 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", - ], - }, - { - id: "intermediate", - name: "Outcome by Cohort", - difficulty: "Intermediate", - topics: ["OpenFeature", "flagd", "Spring Boot", "Java"], - learnings: [ - "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", - ], - }, - { - id: "expert", - name: "Read the Chart", - difficulty: "Expert", - topics: ["OpenFeature", "OpenTelemetry", "Grafana", "Spring Boot"], - learnings: [ - "How the OpenFeature OpenTelemetry hooks (TracesHook and MetricsHook) join flag evaluations to the rest of an application's telemetry without a separate ingestion path", - "How to author your own Hook: a tiny class that copies merged-eval-context attributes onto the active OTelOpenTelemetry 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", - ], - }, - ], - }, - { - id: "the-ai-observatory", - title: "The AI Observatory", - month: "FEB 2026", - story: "Investigate a mysterious bandwidth anomaly at a remote research station by instrumenting its AI system with OpenTelemetry, OpenLLMetry, and Jaeger.", - tags: ["OpenTelemetry", "OpenLLMetry", "Jaeger", "Prometheus", "Python"], - contributor: { - name: "Katharina Sick", - url: "https://ksick.dev/", - aboutHtml: "DevRelDeveloper Relations at Dynatrace and co-organizer of Cloud Native Linz. Passionate about building user-friendly Cloud Native and Kubernetes solutions, with a background in mobile and backend development. Found in tech and sports communities, inline skating rinks, and quiz nights across Europe.", - }, - icon: "Telescope", - levels: [ - { - id: "beginner", - name: "Calibrating the Lens", - difficulty: "Beginner", - topics: ["OpenTelemetry", "OpenLLMetry", "Jaeger"], - learnings: [ - "Instrument Python AI apps with OpenLLMetry", - "Analyze traces in Jaeger", - ], - }, - { - id: "intermediate", - name: "The Distracted Pilot", - difficulty: "Intermediate", - topics: ["OpenTelemetry", "OpenLLMetry", "Jaeger", "Prometheus"], - learnings: [ - "Instrument RAGRetrieval-Augmented Generation pipelines with OpenLLMetry", - "Create custom OpenTelemetry metrics in Python", - "Write PromQL queries & recording rules in Prometheus", - ], - }, - { - id: "expert", - name: "The Noise Filter", - difficulty: "Expert", - topics: ["OpenTelemetry", "OpenLLMetry", "Jaeger"], - learnings: [ - "OpenTelemetry GenAI semantic conventions", - "Tail sampling in the OTelOpenTelemetry Collector", - ], - }, - ], - }, - { - id: "building-cloudhaven", - title: "Building CloudHaven", - 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.", - tags: ["OpenTofu", "Terraform", "GitHub Actions", "Trivy", "TDD"], - contributor: { - name: "Katharina Sick", - url: "https://ksick.dev/", - aboutHtml: "DevRelDeveloper Relations at Dynatrace and co-organizer of Cloud Native Linz. Passionate about building user-friendly Cloud Native and Kubernetes solutions, with a background in mobile and backend development. Found in tech and sports communities, inline skating rinks, and quiz nights across Europe.", - }, - icon: "Building2", - levels: [ - { - id: "beginner", - name: "The Foundation Stones", - difficulty: "Beginner", - topics: ["OpenTofu"], - learnings: [ - "Infrastructure as Code with OpenTofu", - "Remote state management with GCSGoogle Cloud Storage backend", - "Dynamic resource provisioning with for_each", - "Conditional resources with the enabled meta-argument, new in OpenTofu", - ], - }, - { - id: "intermediate", - name: "The Modular Metropolis", - difficulty: "Intermediate", - topics: ["OpenTofu", "TDD"], - learnings: [ - "OpenTofu module testing with tofu test", - "Test-Driven Development (TDD) workflow", - "Input validation with custom rules", - "Refactoring infrastructure safely with moved blocks", - ], - }, - { - id: "expert", - name: "The Guardian Protocols", - difficulty: "Expert", - topics: ["OpenTofu", "GitHub Actions", "Trivy"], - learnings: [ - "GitHub Actions for drift detection and plan/apply", - "Integration tests with service containers", - "Security scanning with Trivy", - ], - }, - ], - }, - { - id: "echoes-lost-in-orbit", - title: "Echoes Lost in Orbit", - month: "DEC 2025", - story: "Restore interstellar communications by fixing broken GitOps setups, progressive delivery systems, and observability pipelines across three galactic missions.", - tags: ["Argo CD", "Argo Rollouts", "OpenTelemetry", "Jaeger", "PromQL"], - contributor: { - name: "Katharina Sick", - url: "https://ksick.dev/", - aboutHtml: "DevRelDeveloper Relations at Dynatrace and co-organizer of Cloud Native Linz. Passionate about building user-friendly Cloud Native and Kubernetes solutions, with a background in mobile and backend development. Found in tech and sports communities, inline skating rinks, and quiz nights across Europe.", - }, - icon: "Satellite", - levels: [ - { - id: "beginner", - name: "Broken Echoes", - difficulty: "Beginner", - topics: ["Argo CD"], - learnings: [ - "Debug GitOpsGit Operations flows with Argo CD", - "ApplicationSet templating & pitfalls", - "Environment isolation & namespaces", - "Sync policies: automated, prune & self-heal", - ], - }, - { - id: "intermediate", - name: "The Silent Canary", - difficulty: "Intermediate", - topics: ["Argo Rollouts", "PromQL"], - learnings: [ - "Progressive delivery with Argo Rollouts", - "Canary deployments & automated analysis", - "Write PromQL queries for health validation", - "Kube-state-metrics for deployment decisions", - ], - }, - { - id: "expert", - name: "Hyperspace Operations & Transport", - difficulty: "Expert", - topics: ["Argo Rollouts", "OpenTelemetry", "Jaeger", "PromQL"], - learnings: [ - "Configure OpenTelemetry Collector pipelines", - "Spanmetrics connector (traces to metrics)", - "Detect idle canaries with traffic validation", - "Distributed tracing with Jaeger", - ], - }, - ], - }, -]; - -/** All unique technology tags across all adventures, for card and filter views. */ -export const SUMMARY_TAGS: string[] = Array.from( - new Set(ADVENTURE_SUMMARIES.flatMap((a) => a.tags)) -).sort(); - -/** Returns level summaries matching a tag, for filtered card views on the home page. */ -export const getLevelSummariesByTag = (tag: string): RelatedLevelSummary[] => - ADVENTURE_SUMMARIES - .filter((a) => a.tags.includes(tag)) - .flatMap((a) => - a.levels.map((level) => ({ - level, - adventureId: a.id, - adventureTitle: a.title, - ...(a.isLive ? { isLive: true } : {}), - ...(a.icon ? { adventureIcon: a.icon } : {}), - })) - ); - -/** - * Community members who contributed an adventure, grouped by person. - * Derived from ADVENTURE_SUMMARIES — import from here instead of "@/data/adventures" - * on pages that do not otherwise need the full adventure dataset (About, Adventures, Challenges). - */ -export const ADVENTURE_CONTRIBUTORS: AdventureContributor[] = Object.values( - ADVENTURE_SUMMARIES - .filter((a): a is AdventureCardSummary & { contributor: NonNullable } => a.contributor !== undefined) - .reduce>((acc, a) => { - const key = a.contributor.name; - if (!acc[key]) { - acc[key] = { name: a.contributor.name, url: a.contributor.url, aboutHtml: a.contributor.aboutHtml, adventures: [] }; - } - acc[key].adventures.push({ id: a.id, title: a.title }); - return acc; - }, {}) -); diff --git a/src/data/adventures/tag-utils.ts b/src/data/adventures/tag-utils.ts deleted file mode 100644 index ce0bc29fd..000000000 --- a/src/data/adventures/tag-utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { SUMMARY_TAGS } from "./summaries"; - -/** Convert a tag display name to a URL-safe slug. */ -export const tagToSlug = (tag: string): string => - tag.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); - -/** Lookup map from slug back to the original tag name. Built from SUMMARY_TAGS so this module - does not import the full generated adventure detail files. */ -const SLUG_TO_TAG: Record = Object.fromEntries( - SUMMARY_TAGS.map((tag) => [tagToSlug(tag), tag]) -); - -/** Resolve a URL slug back to the original tag name, or undefined if not found. */ -export const slugToTag = (slug: string): string | undefined => SLUG_TO_TAG[slug]; diff --git a/src/data/adventures/the-ai-observatory.generated.ts b/src/data/adventures/the-ai-observatory.generated.ts deleted file mode 100644 index d82871c23..000000000 --- a/src/data/adventures/the-ai-observatory.generated.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { CODESPACES_BASE, COMMUNITY_URL } from "@/data/constants"; -import type { Adventure } from "./types"; - -export const THE_AI_OBSERVATORY: Adventure = { - id: "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.", - metaDescription: "The AI Observatory: a hands-on OpenTelemetry, OpenLLMetry, Jaeger adventure on OffOn.", - tags: ["OpenTelemetry", "OpenLLMetry", "Jaeger", "Prometheus", "Python"], - contributor: { - name: "Katharina Sick", - url: "https://ksick.dev/", - aboutHtml: "DevRelDeveloper Relations at Dynatrace and co-organizer of Cloud Native Linz. Passionate about building user-friendly Cloud Native and Kubernetes solutions, with a background in mobile and backend development. Found in tech and sports communities, inline skating rinks, and quiz nights across Europe.", - }, - backstory: [ - "You are stationed at Perimeter Alpha, a research outpost on the newly discovered planet HB-7742. The station is run by HubSystem, a central AI that manages everything from life support to data analysis.", - "Recently, the station's bandwidth usage has spiked to 847% above baseline, but no one knows why. As the systems 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.", - `
-

Credits: The characters of this adventure are borrowed from the Murderbot Diaries series by Martha Wells, a brilliant series that is funny, action-packed, and surprisingly heartwarming. It follows a security unit that hacked its own governor module and now just wants to be left alone to watch media, but keeps getting pulled into human nonsense.

-
`, - ], - levels: [ - { - id: "beginner", - name: "Calibrating the Lens", - difficulty: "Beginner", - topics: ["OpenTelemetry", "OpenLLMetry", "Jaeger"], - learnings: [ - "Instrument Python AI apps with OpenLLMetry", - "Analyze traces in Jaeger", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F03-the-ai-observatory_01-beginner%2Fdevcontainer.json&quickstart=1`, - discussionUrl: `${COMMUNITY_URL}/t/instrument-your-first-llm-adventure-03-beginner-is-live/865/8`, - deadline: "2026-03-08T23:59:00+01:00", - intro: [ - "Something is eating 847% of your station's bandwidth and nobody knows what. Instrument HubSystem with OpenLLMetry, send traces to the OpenTelemetry Collector, and use Jaeger to uncover what the AI is doing behind the scenes.", - ], - backstory: [ - "You're a researcher stationed at Perimeter Alpha, a remote research outpost on the newly discovered planet HB-7742. Your team of six scientists is protected by a single SecUnit, assigned by the corporation to ensure your safety during the survey mission. All station queries flow through HubSystem, the central AI that manages everything from data analysis to status reports.", - "Three weeks in, you notice something odd in your morning diagnostics: communication module usage at 847% above baseline. Nobody's streaming. Nobody's running large data transfers. The planet surveys are on schedule. So what's consuming all that bandwidth?", - "As the station's systems engineer, you decide to investigate. Time to instrument HubSystem with OpenTelemetry and find out what's really going on.", - `
-

Credits: The characters of this adventure are borrowed from the Murderbot Diaries series by Martha Wells, a brilliant series that is funny, action-packed, and surprisingly heartwarming. It follows a security unit that hacked its own governor module and now just wants to be left alone to watch media, but keeps getting pulled into human nonsense.

-
`, - ], - objective: [ - "Enable OpenTelemetry instrumentation for HubSystem using OpenLLMetry", - "Send traces to the OpenTelemetry Collector at http://localhost:30107", - "Analyze traces in Jaeger to find what causes the high bandwidth usage", - "Provide the correct answer in quiz.txt", - ], - architecture: [ - "

All AI and observability infrastructure (Ollama, OpenTelemetry Collector, Jaeger) runs inside Kubernetes, while HubSystem runs as a local Python application outside the cluster.

", - "

This setup has two benefits: it lets you focus on instrumentation without wrestling with containers or Kubernetes deployments when updating the app, and it gives you fast iteration. Edit the Python code, run it, and see traces in Jaeger immediately. No build or deploy cycle.

", - ], - toolbox: [ - { name: "python", description: "programming language used for the HubSystem application" }, - { name: "kubectl", description: "Kubernetes CLICommand Line Interface for interacting with the cluster", url: "https://kubernetes.io/docs/reference/kubectl/" }, - { name: "kubens", description: "fast way to switch between Kubernetes namespaces", url: "https://github.com/ahmetb/kubectx" }, - { name: "k9s", description: "terminal UIUser Interface for managing and inspecting your cluster", url: "https://k9scli.io/" }, - ], - howToPlay: [ - { title: "Wait for Infrastructure", content: "

Wait ~10 minutes for all infrastructure to initialize.

" }, - { title: "Explore the UIs", content: `

Open the Ports tab and navigate to each service:

-
    -
  • Port 30103: Jaeger. Analyze the traces sent by HubSystem.
  • -
` }, - { title: "Instrument the App", content: `

The application code is in ./hubsystem.py. Add OpenTelemetry instrumentation using OpenLLMetry. The OTelOpenTelemetry -Collector and Jaeger are already configured correctly; you only need to instrument the app. You do not need to -interact with Kubernetes directly. The cluster is already running, so focus on the Python code.

` }, - { title: "Run and Investigate", content: `

Run the application, interact with the AI to generate traces, then check Jaeger:

-
make hubsystem
-
` }, - { title: "Answer the Quiz", content: "

Find the trace responsible for the high bandwidth usage and inspect its attributes to answer quiz.txt.

" }, - ], - helpfulLinks: [ - { title: "OpenLLMetry SDK for Python", url: "https://traceloop.com/docs/openllmetry/getting-started-python" }, - { title: "Jaeger documentation", url: "https://www.jaegertracing.io/docs/latest/" }, - ], - 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.", - }, - metaDescription: "Calibrating the Lens: Something is eating 847% of your station's bandwidth and nobody knows what. Instrument HubSystem with OpenLLMetry, send traces to the...", - }, - { - id: "intermediate", - name: "The Distracted Pilot", - difficulty: "Intermediate", - topics: ["OpenTelemetry", "OpenLLMetry", "Jaeger", "Prometheus"], - learnings: [ - "Instrument RAGRetrieval-Augmented Generation pipelines with OpenLLMetry", - "Create custom OpenTelemetry metrics in Python", - "Write PromQL queries & recording rules in Prometheus", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F03-the-ai-observatory_02-intermediate%2Fdevcontainer.json&quickstart=1`, - discussionUrl: `${COMMUNITY_URL}/t/instrument-debug-a-rag-pipeline-adventure-03-intermediate-is-live/936/2`, - deadline: "2026-03-08T23:59:00+01:00", - intro: [ - "ART's RAG pipeline is retrieving entertainment data instead of navigation coordinates and won't calculate your jump. Instrument the full retrieval pipeline with OpenLLMetry, build a custom OTelOpenTelemetry metric to quantify the distraction, and write a Prometheus recording rule to prove it.", - ], - backstory: [ - "You're a rogue SecUnit who just escaped from Preservation Station after being identified. A researcher helped you flee aboard the Perihelion, a university research vessel with a very opinionated AI.", - "The ship's AI agreed to help you disappear. You've nicknamed it ART. The plan: jump to RaviHyral, lay low, and figure out your next move. Except ART was supposed to have the jump coordinates ready an hour ago.", - `

You ping the ship's AI through your internal comm.

-

SecUnit: "ART. Jump coordinates. Now."

-

ART: "I'm multitasking. The coordinates are... being compiled."

-

That's not normal. ART is never vague. You access the ship's diagnostic systems (something you're not supposed to be able to do, but ART hasn't locked you out yet).

`, - "Your mission: diagnose ART's distraction using OpenTelemetry and fix the navigation system before you miss your jump.", - `
-

Credits: The characters of this adventure are borrowed from the Murderbot Diaries series by Martha Wells, a brilliant series that is funny, action-packed, and surprisingly heartwarming. It follows a security unit that hacked its own governor module and now just wants to be left alone to watch media, but keeps getting pulled into human nonsense.

-
`, - ], - objective: [ - "Instrument the full RAG pipeline with OpenLLMetry (add a span named rag.context_assembly with attribute context.categories)", - "Implement a custom metric art.rag.retrieval.count to track how often ART retrieves entertainment vs navigation data", - "Create a Prometheus recording rule to calculate ART's Distraction Ratio", - "Restore the navigation system so ART successfully calculates jump coordinates to RaviHyral", - ], - architecture: [ - "

The ART Pilot System runs as a local Python application outside Kubernetes, using a RAG (Retrieval-Augmented Generation) architecture. AI infrastructure (Ollama for LLMLarge Language Model, Qdrant for vector storage) and observability tools (OpenTelemetry Collector, Jaeger, Prometheus) run inside Kubernetes.

", - "

This setup lets you focus on observability patterns: edit Python code, run it, and see traces and metrics immediately without a build or deploy cycle.

", - ], - toolbox: [ - { name: "python", description: "programming language used for the ART application" }, - { name: "kubectl", description: "Kubernetes CLICommand Line Interface for interacting with the cluster", url: "https://kubernetes.io/docs/reference/kubectl/" }, - { name: "kubens", description: "fast way to switch between Kubernetes namespaces", url: "https://github.com/ahmetb/kubectx" }, - { name: "k9s", description: "terminal UIUser Interface for managing and inspecting your cluster", url: "https://k9scli.io/" }, - ], - howToPlay: [ - { title: "Wait for Infrastructure", content: "

Wait ~15 minutes for all infrastructure to initialize.

" }, - { title: "Explore the UIs", content: `

Open the Ports tab and navigate to each service:

-
    -
  • Port 30102: Prometheus. Explore available metrics and test PromQL queries.
  • -
  • Port 30103: Jaeger. Shows distributed traces from ART to verify that tracing is working end-to-end.
  • -
` }, - { title: "Instrument and Configure", content: `

The application code is in ./art.py. Instrument it with OpenLLMetry and add the custom metric. The Prometheus recording rules are in ./manifests/prometheus-rule.yaml. After changing the rule file, apply it to the cluster:

-
make apply
-
` }, - { title: "Generate Traffic", content: `

Run the application to interact with ART ("Calculate jump"), or generate continuous traffic for your metric graphs:

-
make art
-# or for continuous traffic:
-make traffic
-
` }, - { title: "Fix the Navigation", content: "

Verify traces in Jaeger and the recording rule in Prometheus. Fix the navigation system so ART returns jump coordinates to RaviHyral.

" }, - ], - helpfulLinks: [ - { title: "OpenLLMetry SDK for Python", url: "https://traceloop.com/docs/openllmetry/getting-started-python" }, - { title: "OpenTelemetry Python metrics", url: "https://opentelemetry.io/docs/languages/python/instrumentation/#metrics" }, - { title: "Prometheus recording rules", url: "https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/" }, - { title: "Qdrant filtering", url: "https://qdrant.tech/documentation/concepts/filtering/" }, - ], - 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.", - }, - metaDescription: "The Distracted Pilot: ART's RAG pipeline is retrieving entertainment data instead of navigation coordinates and won't calculate your jump. Instrument the...", - }, - { - id: "expert", - name: "The Noise Filter", - difficulty: "Expert", - topics: ["OpenTelemetry", "OpenLLMetry", "Jaeger"], - learnings: [ - "OpenTelemetry GenAI semantic conventions", - "Tail sampling in the OTelOpenTelemetry Collector", - ], - codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F03-the-ai-observatory_03-expert%2Fdevcontainer.json&quickstart=1`, - discussionUrl: `${COMMUNITY_URL}/t/reduce-telemetry-noise-adventure-03-expert-is-live/999/1`, - deadline: "2026-03-08T23:59:00+01:00", - intro: [ - "ART is flooding Jaeger with 40,000 non-standard spans an hour. Fix the chat span to follow OpenTelemetry GenAI semantic conventions with proper token usage attributes, then configure tail sampling in the Collector to keep only traces that contain errors or exceed 5 seconds.", - ], - backstory: [ - "You made it to RaviHyral. The Perihelion docked at Outpost Verada, a small independent research station run by a loose collective of academics who agreed to look the other way. In exchange, ART offered to share its observability data with the station's monitoring team.", - `

That was three hours ago. Now the station's lead engineer is at your docking port, looking annoyed.

-

Engineer: "Your ship's AI is flooding our Jaeger instance. Do you have any idea how many spans it's generating? We can't find anything in there."

-

SecUnit: "ART."

-

ART: "Comprehensive telemetry is a feature."

-

Engineer: "It's 40,000 spans an hour. Every healthy query. Every token. It doesn't even follow conventions. We only care about failures and anomalies, the things that actually need attention."

-

SecUnit: "ART. Fix it."

-

ART: "...Fine."

`, - "The engineer hands you access to the collector config and the application code, then walks away. Two problems to fix. ART's spans don't follow OTel GenAI semantic conventions, and the collector is forwarding everything.", - `
-

Credits: The characters of this adventure are borrowed from the Murderbot Diaries series by Martha Wells, a brilliant series that is funny, action-packed, and surprisingly heartwarming. It follows a security unit that hacked its own governor module and now just wants to be left alone to watch media, but keeps getting pulled into human nonsense.

-
`, - ], - objective: [ - "Fix ART's chat span to follow OpenTelemetry GenAI semantic conventions, including token usage attributes", - "Configure tail sampling in the OpenTelemetry Collector to keep only traces that contain errors or take longer than 5 seconds", - ], - architecture: [ - "

Same setup as the intermediate level: the ART Pilot System runs as a local Python application outside Kubernetes with a RAGRetrieval-Augmented Generation architecture. AI infrastructure (Ollama, Qdrant) and observability tools (OpenTelemetry Collector, Jaeger) run inside Kubernetes.

", - ], - toolbox: [ - { name: "python", description: "programming language used for the ART application" }, - { name: "kubectl", description: "Kubernetes CLICommand Line Interface for interacting with the cluster", url: "https://kubernetes.io/docs/reference/kubectl/" }, - { name: "kubens", description: "fast way to switch between Kubernetes namespaces", url: "https://github.com/ahmetb/kubectx" }, - { name: "k9s", description: "terminal UIUser Interface for managing and inspecting your cluster", url: "https://k9scli.io/" }, - ], - howToPlay: [ - { title: "Wait for Infrastructure", content: "

Wait ~15 minutes for all infrastructure to initialize.

" }, - { title: "Explore the UIs", content: `

Open the Ports tab and navigate to each service:

-
    -
  • Port 30103: Jaeger. Verify your spans look correct and that tail sampling works as expected.
  • -
` }, - { title: "Fix Instrumentation and Sampling", content: `

Fix two things:

-
    -
  1. The application code in ./art.py: update the chat span to follow OpenTelemetry GenAI semantic conventions, including token usage attributes.
  2. -
  3. The collector config in ./manifests/otel-collector-config.yaml: configure tail sampling to keep only traces that contain errors or take longer than 5 seconds.
  4. -
` }, - { title: "Apply and Test", content: `

After changing art.py, restart traffic to pick up new instrumentation. After changing the collector config, apply it:

-
kubectl apply -f manifests/otel-collector-config.yaml -n otel
-kubectl rollout restart deployment/collector -n otel
-
-

Then generate traces:

-
make traffic
-
-

Verify in Jaeger that spans follow conventions and only errors and slow traces appear.

` }, - ], - helpfulLinks: [ - { title: "OpenTelemetry GenAI semantic conventions", url: "https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/" }, - { title: "OpenTelemetry Python: recording exceptions", url: "https://opentelemetry.io/docs/languages/python/instrumentation/#record-exceptions" }, - { title: "OTel Collector tail sampling processor", url: "https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/tailsamplingprocessor" }, - { title: "Python contextlib.contextmanager", url: "https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager" }, - ], - 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.", - }, - metaDescription: "The Noise Filter: ART is flooding Jaeger with 40,000 non-standard spans an hour. Fix the chat span to follow OpenTelemetry GenAI semantic conventions with...", - }, - ], -}; diff --git a/src/data/adventures/types.ts b/src/data/adventures/types.ts index b8b189a1a..9560e9e92 100644 --- a/src/data/adventures/types.ts +++ b/src/data/adventures/types.ts @@ -1,8 +1,7 @@ // Prose fields (learnings, audience, objective, step titles and content, // tool descriptions, contributor bios, rewards text, story, intro, backstory, -// scenario) contain pre-rendered HTML generated at build time by the -// adventure generator. Always render them with dangerouslySetInnerHTML, -// never as {value} directly. +// scenario) contain pre-rendered HTML generated at build time by the content +// loader. Render via set:html (Astro) or v-html (Vue); never as {value} directly. /** A tool that ships pre-configured inside the level's Codespace. */ export type ToolboxItem = { @@ -11,7 +10,7 @@ export type ToolboxItem = { url?: string; } -/** One step in the Walkthrough section. content is pre-rendered HTML generated at build time and rendered via dangerouslySetInnerHTML in MarkdownContent. */ +/** One step in the Walkthrough section. content is pre-rendered HTML generated at build time and rendered via set:html in Astro. */ export type WalkthroughStep = { title: string; content: string; @@ -30,12 +29,6 @@ export type HelpfulLink = { description?: string; } -/** A player entry for the top-players leaderboard. Currently defined on AdventureLevel but not consumed by any component. */ -export type TopPlayer = { - username: string; - count: number; -} - /** Placeholder for a level that hasn't shipped yet. Rendered in the "More levels" sidebar card. */ export type UpcomingLevel = { name: string; @@ -52,12 +45,12 @@ export type AdventureLevel = { learnings: string[]; codespacesUrl: string; discussionUrl: string; - // Submission deadline for this level (e.g. "10 December 2025 at 09:00 CET"). Only shown when rewards are active. + // Submission deadline for this level (ISO 8601 string after parsing). Only shown when rewards are active. deadline?: string; // Short narrative hook shown directly under the page title. hook?: string; // Brief intro paragraph(s) shown under the page title before the main content. - intro: string[]; + intro?: string[]; // Narrative backstory paragraphs shown as a collapsible scenario section. backstory?: string[]; // Concrete acceptance criteria shown as the "Objective" card. @@ -84,12 +77,8 @@ export type AdventureLevel = { helpfulLinks?: HelpfulLink[]; // Verification card rendered as the final section. verification: VerificationInfo; - // Optional SEO meta description (max 160 chars). When absent, ChallengeDetail.tsx generates one from level name, intro, and topics. - metaDescription?: string; - // Unused fields — real solver and leaderboard data is fetched at runtime by - // useDiscussionPosts and useAdventureLeaderboard. No component reads these. - solvedCount?: number; - topPlayers?: TopPlayer[]; + // SEO meta description (max 160 chars). Always set by the content loader. + metaDescription: string; } export type AdventureRewardTier = { @@ -111,8 +100,8 @@ export type Adventure = { title: string; month: string; story: string; - // SEO meta description (max 160 chars). Always set by the generator; can be overridden with meta_description in YAML. - metaDescription?: string; + // SEO meta description (max 160 chars). Always set by the content loader. + metaDescription: string; tags: string[]; levels: AdventureLevel[]; contributor?: { name: string; url?: string; aboutHtml?: string }; @@ -120,7 +109,7 @@ export type Adventure = { backstory?: string[]; // Context paragraphs explaining what technologies or concepts the adventure covers. overview?: string[]; - // Lucide React icon name representing this adventure (e.g. 'FlaskConical'). + // Lucide icon name representing this adventure (e.g. 'FlaskConical'). icon?: string; rewards?: AdventureRewards; // Mock placeholders for levels that haven't shipped yet. Rendered in the @@ -131,51 +120,8 @@ export type Adventure = { export type AdventureContributor = { name: string; url?: string; - /** Pre-rendered HTML from markdown — always render via InlineProse or dangerouslySetInnerHTML. */ + /** Pre-rendered HTML from markdown. Always render via InlineProse or set:html. */ aboutHtml?: string; adventures: { id: string; title: string }[]; }; -/** A level with its parent adventure context, returned when filtering by tag. */ -export type RelatedLevel = { - level: AdventureLevel; - adventureId: string; - adventureTitle: string; -}; - -/** - * Lightweight level shape used for card and filter views on the home/challenges pages. - * Contains only the fields needed to render AdventureCard and FilteredLevelCard. - * Generated into summaries.ts. Do not import the full AdventureLevel where this suffices. - */ -export type AdventureLevelSummary = { - id: string; - name: string; - difficulty: "Beginner" | "Intermediate" | "Expert"; - topics: string[]; - learnings: string[]; - estimatedTime?: string; -}; - -/** Lightweight adventure shape for card grid views. Generated into summaries.ts. */ -export type AdventureCardSummary = { - id: string; - title: string; - month: string; - story: string; - tags: string[]; - levels: AdventureLevelSummary[]; - contributor?: { name: string; url?: string; aboutHtml?: string }; - /** True when the adventure has an active rewards window or any level deadline in the future. */ - isLive?: boolean; - icon?: string; -}; - -/** A level summary with its parent adventure context, for filtered card views. */ -export type RelatedLevelSummary = { - level: AdventureLevelSummary; - adventureId: string; - adventureTitle: string; - isLive?: boolean; - adventureIcon?: string; -}; diff --git a/src/data/solutions/index.ts b/src/data/solutions/index.ts deleted file mode 100644 index db98508c4..000000000 --- a/src/data/solutions/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -// This file is auto-generated by scripts/generate-solutions.mjs. Do not edit by hand. -import type { Solution } from "./types"; - -import { solution as solution_echoes_lost_in_orbit_beginner } from "./echoes-lost-in-orbit/beginner"; -import { solution as solution_echoes_lost_in_orbit_expert } from "./echoes-lost-in-orbit/expert"; -import { solution as solution_echoes_lost_in_orbit_intermediate } from "./echoes-lost-in-orbit/intermediate"; - -export const SOLUTIONS: Solution[] = [ - solution_echoes_lost_in_orbit_beginner, - solution_echoes_lost_in_orbit_expert, - solution_echoes_lost_in_orbit_intermediate, -]; diff --git a/src/data/solutions/manifest.ts b/src/data/solutions/manifest.ts deleted file mode 100644 index 875c907d6..000000000 --- a/src/data/solutions/manifest.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file is auto-generated by scripts/generate-solutions.mjs. Do not edit by hand. - -export const SOLUTION_IDS: Set = new Set([ - "echoes-lost-in-orbit/beginner", - "echoes-lost-in-orbit/expert", - "echoes-lost-in-orbit/intermediate", -]); diff --git a/src/data/solutions/types.ts b/src/data/solutions/types.ts index fb366bd36..69fc22d64 100644 --- a/src/data/solutions/types.ts +++ b/src/data/solutions/types.ts @@ -1,3 +1,5 @@ +// html fields in SolutionBlock are authored by trusted contributors (not user +// input) and are rendered via set:html in SolutionBlocks.astro. export type SolutionBlock = | { type: "text"; html: string } | { type: "code"; language: string; title?: string; code: string } diff --git a/src/data/sponsors.ts b/src/data/sponsors.ts index afdc119b7..dd39d1c47 100644 --- a/src/data/sponsors.ts +++ b/src/data/sponsors.ts @@ -1,5 +1,5 @@ -import dtLogoDark from "@/assets/Dynatrace_Logo_color_negative_horizontal.svg"; -import dtLogoLight from "@/assets/Dynatrace_Logo_color_positive_horizontal.svg"; +// Sponsor data. Logo paths are relative to BASE_URL (served from public/); +// prepend `import.meta.env.BASE_URL` before use. export type Sponsor = { name: string; @@ -17,8 +17,8 @@ export const SPONSORS: Sponsor[] = [ { name: "Dynatrace", url: "https://dynatrace.com", - logoDark: dtLogoDark, - logoLight: dtLogoLight, + logoDark: "brand/Dynatrace_Logo_color_negative_horizontal.svg", + logoLight: "brand/Dynatrace_Logo_color_positive_horizontal.svg", }, ]; diff --git a/src/data/team.ts b/src/data/team.ts index 25400a63d..2c210edfe 100644 --- a/src/data/team.ts +++ b/src/data/team.ts @@ -1,4 +1,6 @@ -import { KATHARINA_SICK } from "@/data/adventures/contributors"; +import type { AdventureContributor } from "./adventures/types"; + +// Board member data. export type BoardMember = { name: string; @@ -12,25 +14,29 @@ export const BOARD_MEMBERS: BoardMember[] = [ { name: "David Hirsch", url: "https://davidpeterhirsch.com/", - about: "Head of Community and Open Source at Dynatrace, leading a cross-functional Community and OSPO team. Co-founder of KCD Austria with a focus on open source governance, ecosystem building, and turning open source into a strategic asset. Recently completed an MBA with a thesis on internal development versus open source.", + about: + "Head of Community and Open Source at Dynatrace, leading a cross-functional Community and OSPO team. Co-founder of KCD Austria with a focus on open source governance, ecosystem building, and turning open source into a strategic asset. Recently completed an MBA with a thesis on internal development versus open source.", image: "team/david.webp", }, { - name: KATHARINA_SICK.name, - url: KATHARINA_SICK.url, - about: KATHARINA_SICK.about ?? "", + name: "Katharina Sick", + url: "https://ksick.dev/", + about: + "Senior Developer Programs Engineer at Dynatrace and co-organizer of Cloud Native Linz. Passionate about building user-friendly Cloud Native and Kubernetes solutions, with a background in mobile and backend development. Found in tech and sports communities, inline skating rinks, and quiz nights across Europe.", image: "team/katharina.webp", }, { name: "Kenyatta Forbes", url: "https://www.linkedin.com/in/kenyatta-f/", - about: "Sr Program Manager at Dynatrace, focused on cross-functional collaboration at the intersection of education, open source, and technology. Previously led product pilots reaching 189 million Google Classroom users at Google for Education and ran open internet programs at Mozilla. Started her career as an educator and technology coordinator for Chicago Public Schools.", + about: + "Sr Program Manager at Dynatrace, focused on cross-functional collaboration at the intersection of education, open source, and technology. Previously led product pilots reaching 189 million Google Classroom users at Google for Education and ran open internet programs at Mozilla. Started her career as an educator and technology coordinator for Chicago Public Schools.", image: "team/kenyatta.webp", }, { name: "Sinduri Guntupalli", url: "https://www.linkedin.com/in/sinduri-guntupalli-307542131/", - about: "Sr Developer Programs Engineer at Dynatrace, with a background in web development, configuration management, web analytics, and SEO. Active in the Drupal community as Marketing Manager for Drupal Austria and recipient of the Women in Drupal Award (Build). Open source enthusiast, positivity advocate, and continuous learner.", + about: + "Sr Developer Programs Engineer at Dynatrace, with a background in web development, configuration management, web analytics, and SEO. Active in the Drupal community as Marketing Manager for Drupal Austria and recipient of the Women in Drupal Award (Build). Open source enthusiast, positivity advocate, and continuous learner.", image: "team/sinduri.webp", }, // TODO: replace placeholder with confirmed board member @@ -44,3 +50,27 @@ export const BOARD_MEMBERS: BoardMember[] = [ about: "Board seat to be announced.", }, ]; + +// Update manually when adventure contributor fields change. +export const ADVENTURE_CONTRIBUTORS: AdventureContributor[] = [ + { + name: "Katharina Sick", + url: "https://ksick.dev/", + aboutHtml: + "DevRel at Dynatrace and co-organizer of Cloud Native Linz. Passionate about building user-friendly Cloud Native and Kubernetes solutions, with a background in mobile and backend development. Found in tech and sports communities, inline skating rinks, and quiz nights across Europe.", + adventures: [ + { id: "dead-reckoning", title: "Dead Reckoning" }, + { id: "lex-imperfecta", title: "Lex Imperfecta" }, + { id: "the-ai-observatory", title: "The AI Observatory" }, + { id: "building-cloudhaven", title: "Building CloudHaven" }, + { id: "echoes-lost-in-orbit", title: "Echoes Lost in Orbit" }, + ], + }, + { + name: "Simon Schrottner", + url: "https://schrottner.at/", + aboutHtml: + "CNCF Ambassador and maintainer of OpenFeature and JUnit Pioneer. Helps teams release faster and with more confidence through open standards, feature flagging, and the communities that make both possible. A familiar face at KubeCon EU, Devoxx, ContainerDays, and meetups across Europe.", + adventures: [{ id: "blind-by-design", title: "Blind by Design" }], + }, +]; diff --git a/src/entry.client.tsx b/src/entry.client.tsx deleted file mode 100644 index 0cce75bde..000000000 --- a/src/entry.client.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { startTransition, StrictMode } from "react"; -import { hydrateRoot } from "react-dom/client"; -import { HydratedRouter } from "react-router/dom"; - -// When a static host (GitHub Pages, local preview) serves the prerendered -// 404.html as a fallback for an unknown URL, the embedded __reactRouterContext -// stream contains route-match state for the /404 route, not the actual browser -// URL. HydratedRouter uses that stale route state, which triggers a client-side -// route-match mismatch and React error #418. React recovers automatically -// (it switches to a client render of the correct NotFound page), but the error -// and any resulting font-preload warnings pollute the console. -// -// Fix: detect the mismatch via the canonical URL baked into the HTML and pass -// onRecoverableError: () => {} so React's auto-recovery is silent. The user -// sees the correct 404 UI regardless; this only suppresses console noise. -function isStaleServe(): boolean { - try { - const canonical = document.querySelector('link[rel="canonical"]'); - if (!canonical?.href) return false; - const prerenderedPath = new URL(canonical.href).pathname.replace(/\/$/, "") || "/"; - const currentPath = window.location.pathname.replace(/\/$/, "") || "/"; - return prerenderedPath !== currentPath; - } catch { - return false; - } -} - -startTransition(() => { - hydrateRoot( - document, - - - , - isStaleServe() - ? { - // HydratedRouter requires __reactRouterContext to be present; it cannot - // be cleared. Instead, suppress the recoverable hydration error (#418/#425) - // that React raises when the prerendered route state doesn't match the - // current URL. React auto-recovers to a client render of the correct page. - // Only suppress known hydration errors; log everything else. - onRecoverableError: (error: unknown) => { - const msg = error instanceof Error ? error.message : String(error); - if (msg.includes("418") || msg.includes("425")) return; - console.error(error); - }, - } - : undefined, - ); -}); diff --git a/src/entry.server.tsx b/src/entry.server.tsx deleted file mode 100644 index cbc2f9837..000000000 --- a/src/entry.server.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { PassThrough } from "node:stream"; -import { renderToPipeableStream } from "react-dom/server"; -import { ServerRouter } from "react-router"; -import type { AppLoadContext, EntryContext } from "react-router"; - -const PRERENDER_TIMEOUT_MS = 5000; - -export default function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - entryContext: EntryContext, - _loadContext: AppLoadContext, -): Promise { - return new Promise((resolve, reject) => { - const body = new PassThrough(); - const chunks: Buffer[] = []; - body.on("data", (chunk: Buffer) => chunks.push(chunk)); - body.on("end", () => { - responseHeaders.set("Content-Type", "text/html"); - resolve( - new Response(Buffer.concat(chunks), { - headers: responseHeaders, - status: responseStatusCode, - }), - ); - }); - body.on("error", reject); - - const { pipe, abort } = renderToPipeableStream( - , - { - onAllReady() { - pipe(body); - }, - onError(error: unknown) { - console.error(error); - }, - }, - ); - - setTimeout(abort, PRERENDER_TIMEOUT_MS); - }); -} diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 000000000..e91858e1d --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/hooks/useAbbrTooltips.ts b/src/hooks/useAbbrTooltips.ts deleted file mode 100644 index ec5f9915f..000000000 --- a/src/hooks/useAbbrTooltips.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { useEffect, type RefObject } from "react"; - -// Single abbreviation-tooltip implementation shared by the component and -// dangerouslySetInnerHTML prose (MarkdownContent), so JSX and pre-rendered -// content behave identically. For every abbr[data-title] inside the container it: -// - replaces the CSS ::after tooltip with a fixed-position portal appended to -// document.body, so it escapes overflow clipping and is clamped to the -// viewport; reveals on hover and focus, click forces focus (iOS touch), and -// Escape hides it without moving focus (WCAG 1.4.13 dismissible). -// It also converts any leftover (e.g. hardcoded HTML) to data-title -// plus an adjacent sr-only expansion span, matching what the generator and the -// component already emit. The CSS `abbr[data-title]::after` rule is the -// no-JS fallback. -export function useAbbrTooltips( - containerRef: RefObject, - deps: unknown[] = [], -): void { - useEffect(() => { - const el = containerRef.current; - if (!el) return; - const cleanup: (() => void)[] = []; - - // Safety net for raw that never went through the generator. - el.querySelectorAll("abbr[title]").forEach((abbr) => { - const text = abbr.getAttribute("title") ?? ""; - abbr.setAttribute("data-title", text); - abbr.removeAttribute("title"); - const next = abbr.nextElementSibling; - if (!(next && next.classList.contains("sr-only"))) { - const span = document.createElement("span"); - span.className = "sr-only"; - span.textContent = text; - abbr.after(span); - } - }); - - el.querySelectorAll("abbr[data-title]").forEach((abbrEl) => { - const title = abbrEl.getAttribute("data-title") ?? ""; - - const tip = document.createElement("span"); - tip.setAttribute("aria-hidden", "true"); - tip.style.cssText = - "position:fixed;z-index:9999;display:none;" + - "padding:0.25rem 0.5rem;font-size:0.75rem;line-height:1rem;" + - "background:hsl(var(--foreground));color:hsl(var(--background));" + - "border-radius:0.25rem;word-break:break-word;white-space:normal;" + - "box-shadow:0 2px 8px rgba(0,0,0,0.3);"; - tip.textContent = title; - document.body.appendChild(tip); - - // Suppress the CSS ::after tooltip now that the portal is live. - abbrEl.classList.add("abbr-js-tooltip"); - - let hideTimer: ReturnType | null = null; - const clearHide = (): void => { - if (hideTimer !== null) { clearTimeout(hideTimer); hideTimer = null; } - }; - // Position below the abbr, clamped so the tooltip never overflows the - // viewport right edge (fonts loaded and layout stable at show time). - const place = (): void => { - const rect = abbrEl.getBoundingClientRect(); - const tipMaxWidth = Math.min(160, Math.max(80, window.innerWidth - rect.left - 16)); - const left = Math.max(8, Math.min(rect.left, window.innerWidth - tipMaxWidth - 8)); - tip.style.maxWidth = `${tipMaxWidth}px`; - tip.style.top = `${rect.bottom + 6}px`; - tip.style.left = `${left}px`; - }; - const positionAndShow = (): void => { clearHide(); place(); tip.style.display = "block"; }; - const scheduleHide = (): void => { - hideTimer = setTimeout(() => { tip.style.display = "none"; hideTimer = null; }, 100); - }; - const immediateHide = (): void => { clearHide(); tip.style.display = "none"; }; - const reposition = (): void => { if (tip.style.display === "block") place(); }; - // Hoverable: keep the tooltip while the pointer is on it. Guard so a mouse - // leaving the tooltip does not hide it while the abbr is keyboard-focused. - const onTipMouseLeave = (): void => { if (document.activeElement !== abbrEl) scheduleHide(); }; - // iOS Safari does not reliably focus a tabindex-only element on tap; - // attaching a click handler makes it dispatch the tap and forces focus. - const onClick = (): void => { abbrEl.focus(); }; - const onKeyDown = (e: Event): void => { - if ((e as KeyboardEvent).key === "Escape") { e.preventDefault(); immediateHide(); } - }; - - abbrEl.addEventListener("mouseenter", positionAndShow); - abbrEl.addEventListener("mouseleave", scheduleHide); - tip.addEventListener("mouseenter", clearHide); - tip.addEventListener("mouseleave", onTipMouseLeave); - abbrEl.addEventListener("click", onClick); - abbrEl.addEventListener("focus", positionAndShow); - abbrEl.addEventListener("blur", immediateHide); - abbrEl.addEventListener("keydown", onKeyDown); - window.addEventListener("scroll", reposition, { capture: true }); - window.addEventListener("resize", reposition); - - cleanup.push(() => { - abbrEl.removeEventListener("mouseenter", positionAndShow); - abbrEl.removeEventListener("mouseleave", scheduleHide); - tip.removeEventListener("mouseenter", clearHide); - tip.removeEventListener("mouseleave", onTipMouseLeave); - abbrEl.removeEventListener("click", onClick); - abbrEl.removeEventListener("focus", positionAndShow); - abbrEl.removeEventListener("blur", immediateHide); - abbrEl.removeEventListener("keydown", onKeyDown); - window.removeEventListener("scroll", reposition, { capture: true }); - window.removeEventListener("resize", reposition); - clearHide(); - abbrEl.classList.remove("abbr-js-tooltip"); - tip.remove(); - }); - }); - - return () => cleanup.forEach((fn) => fn()); - // eslint-disable-next-line react-hooks/exhaustive-deps -- deps are caller-provided; the effect reads only DOM inside containerRef - }, deps); -} diff --git a/src/hooks/useAdventureLeaderboard.ts b/src/hooks/useAdventureLeaderboard.ts deleted file mode 100644 index d4462aef5..000000000 --- a/src/hooks/useAdventureLeaderboard.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useState, useEffect } from "react"; - -export type LeaderboardRow = { - rank: number; - username: string; - avatarUrl?: string; - points: number; - challengesSolved?: number; - beginnerPoints?: number; - intermediatePoints?: number; - expertPoints?: number; - singlePoints?: number; - breakdown?: string; -}; - -type LeaderboardData = { - updatedAt: string | null; - rows: LeaderboardRow[]; -}; - -// Exported so tests can inject a mock loader without Vitest dynamic-import cost. -export type LeaderboardLoader = (adventureId: string) => Promise; - -const leaderboardModules = import.meta.glob("@/data/adventures/**/leaderboard.json"); - -const defaultLoader: LeaderboardLoader = async (adventureId) => { - const key = `/src/data/adventures/${adventureId}/leaderboard.json`; - const loader = leaderboardModules[key]; - if (!loader) return { updatedAt: null, rows: [] }; - const mod = await loader() as { default: LeaderboardData }; - return mod.default; -}; - -export type LeaderboardResult = { - rows: LeaderboardRow[]; - updatedAt: string | null; -}; - -/** - * Loads adventure leaderboard data from the per-adventure leaderboard.json file. - * Data is refreshed hourly by the GitHub Actions workflow. - * @param adventureId - The adventure slug (e.g. "blind-by-design"). - * @param loader - Optional loader for testing; defaults to dynamically importing the JSON. - */ -export function useAdventureLeaderboard( - adventureId: string, - loader: LeaderboardLoader = defaultLoader, -): LeaderboardResult { - const [rows, setRows] = useState([]); - const [updatedAt, setUpdatedAt] = useState(null); - - useEffect(() => { - if (!adventureId) return; - let cancelled = false; - loader(adventureId) - .then((data) => { - if (cancelled) return; - setRows(data.rows ?? []); - setUpdatedAt(data.updatedAt ?? null); - }) - .catch(() => { - if (!cancelled) { - setRows([]); - setUpdatedAt(null); - } - }); - return () => { cancelled = true; }; - }, [adventureId, loader]); - - return { rows, updatedAt }; -} diff --git a/src/hooks/useClickTracking.ts b/src/hooks/useClickTracking.ts deleted file mode 100644 index e19fb5b4f..000000000 --- a/src/hooks/useClickTracking.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { useEffect } from "react"; -import { useConsent } from "@/hooks/useConsent"; - -const TRACKED_SELECTOR = "a, button"; -// GA4 silently truncates string parameter values at 100 chars. Truncate -// ourselves so the limit is visible in the source rather than discovered -// through missing-tail data in reports. -const MAX_CLICK_TEXT_LENGTH = 100; -// Skip-nav link target. Defined in Layout.tsx and every page's
. -// Excluded from tracking because it fires on every keyboard Tab+Enter and -// reflects assistive-tech navigation, not user intent. -const SKIP_NAV_HREF = "#main-content"; - -// Attaches a delegated document-level click listener that fires a GA4 -// `click_event` for clicks that resolve to an or