From 5d881debf14aae4f6545e050df3e380375ce4225 Mon Sep 17 00:00:00 2001 From: Christopher Harrison Date: Mon, 14 Sep 2026 13:38:51 -0700 Subject: [PATCH 1/3] docs: simplify workshop authoring and verification guidance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 2 +- .github/instructions/astro.instructions.md | 2 +- .github/pull_request_template.md | 10 +- .github/skills/README.md | 4 +- .github/skills/build-and-verify-docs/SKILL.md | 115 ++++++------------ .../skills/check-content-alignment/SKILL.md | 90 ++++---------- .../skills/make-repo-contribution/SKILL.md | 2 +- .../skills/validate-site-playwright/SKILL.md | 2 +- .github/workflows/pages.yml | 2 +- AUTHORING.md | 36 +++--- CONTRIBUTING.md | 3 +- README.md | 2 +- 12 files changed, 87 insertions(+), 183 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d1afbba6..be1d8f5e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -46,7 +46,7 @@ Because inline copies can drift, run the `check-content-alignment` skill after e ## Building, previewing, and verifying -The tooling for building, previewing, and verifying the site — dev server, clean build, the page-count invariant, and the lychee link check — lives in the [`build-and-verify-docs`](skills/build-and-verify-docs/SKILL.md) skill. Run that verification sequence before every commit, and don't commit if any step fails. +The tooling for building, previewing, and verifying the site lives in the [`build-and-verify-docs`](skills/build-and-verify-docs/SKILL.md) skill. Run that verification sequence before every commit, and don't commit if any step fails. Derive expected pages from the current content and configuration rather than fixed totals. Before opening or updating a PR, also make the **PR-time consistency pass** documented in that skill — a structural-drift sweep (renamed paths, stale skill/instruction references, CI claims, repository-structure trees, and copied prose alignment) that the build and link check can't catch. diff --git a/.github/instructions/astro.instructions.md b/.github/instructions/astro.instructions.md index 26e3c0a6..19502d94 100644 --- a/.github/instructions/astro.instructions.md +++ b/.github/instructions/astro.instructions.md @@ -20,4 +20,4 @@ This is a docs wrapper. Don't add interactive framework islands (Svelte, React, ## Building and verifying -After changing `astro.config.mjs` or anything under `website/src/`, build and verify the site with the [`build-and-verify-docs`](../skills/build-and-verify-docs/SKILL.md) skill. Its page-count invariant is the tripwire for unexpected routed pages: Starlight emits each of the 36 workshop routes for the root language and five configured locales, then adds the legacy redirect, for 217 built `index.html` pages excluding the 404 page. If the count changes without a corresponding route or locale change, check the locale layout under `docs/` and the underscore-directory exclude in `src/content.config.ts`. +After changing `astro.config.mjs` or anything under `website/src/`, use the [`build-and-verify-docs`](../skills/build-and-verify-docs/SKILL.md) skill. Check affected routes and translations against the current source, locale configuration, and content-loader rules. Confirm support assets remain excluded from routing; do not maintain fixed page totals in authoring guidance. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 43ed097c..50b21681 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,12 +14,12 @@ ## Verification - + -- [ ] `cd website && rm -rf dist && npm run build` succeeds (target: 36 routes × 6 locales + 1 redirect = 217 built pages excluding 404; build reports 218 HTML files including 404, or note any intentional change) -- [ ] Lychee link check passes: - `mkdir -p /tmp/lychee-root && ln -sfn $PWD/website/dist /tmp/lychee-root/copilot-workshops && lychee --offline --no-progress --root-dir /tmp/lychee-root 'website/dist/**/*.html'` -- [ ] External GitHub URLs that I changed have been clicked manually (lychee runs offline) +- [ ] Type checks and a clean site build pass. +- [ ] Affected pages and translations match their source and intended routes. +- [ ] Offline internal link checks pass. +- [ ] Changed external links have been checked separately. ## Screenshots diff --git a/.github/skills/README.md b/.github/skills/README.md index 785acb30..3890c014 100644 --- a/.github/skills/README.md +++ b/.github/skills/README.md @@ -10,8 +10,8 @@ These encode conventions unique to this Astro + Starlight workshop content repo. | Skill | What it does | Use it when | |---|---|---| -| [`build-and-verify-docs`](./build-and-verify-docs/SKILL.md) | Canonical build/preview/verify process: dev server, clean build, page-count invariant, lychee link check, and the **PR-time consistency pass** for structural drift. | Building, previewing, or verifying any change under `docs/`; before every commit/PR. | -| [`check-content-alignment`](./check-content-alignment/SKILL.md) | Scans a diff (staged/unstaged or a branch range) for content changes, then finds other lessons that duplicate or parallel the changed prose — formerly-shared passages now copied across pages, and the same concept taught across the VS Code / CLI / App / Cloud harnesses — so duplicated copies don't drift out of sync. Reports candidate files and line ranges; does not edit content. | After editing lesson content under `docs/`, before committing/opening a PR, to find other pages that should change too. | +| [`build-and-verify-docs`](./build-and-verify-docs/SKILL.md) | Canonical preview and verification process: type checks, clean build, affected-page inspection, offline links, and a structural consistency pass. | Building, previewing, or verifying the site; before every commit/PR. | +| [`check-content-alignment`](./check-content-alignment/SKILL.md) | Uses the diff and current content tree to find inconsistent repeated passages, parallel concepts, translations, and cross-references. Reports candidates without editing content. | After lesson edits or when reviewing content drift. | | [`validate-site-playwright`](./validate-site-playwright/SKILL.md) | Optional deeper **browser** QA: drives the Playwright MCP server against a local preview to confirm pages render, catch console/hydration errors, find broken images, and confirm Starlight components mounted. | A deeper render/visual pass before a PR that changes how pages render. Complements (doesn't replace) `build-and-verify-docs`. | ## General-purpose skills diff --git a/.github/skills/build-and-verify-docs/SKILL.md b/.github/skills/build-and-verify-docs/SKILL.md index f2453bbd..d5b4e446 100644 --- a/.github/skills/build-and-verify-docs/SKILL.md +++ b/.github/skills/build-and-verify-docs/SKILL.md @@ -1,115 +1,68 @@ --- name: build-and-verify-docs -description: Build, preview, and verify the Copilot Workshops Astro + Starlight workshop site before committing or opening a PR. Use whenever an author or agent is about to build the site, run a local preview/dev server, check links with lychee, confirm the page-count invariant, run the pre-commit verification sequence for any change under `docs/` (content) or `website/` (tooling), or make a PR-time consistency pass to catch structural drift (renamed paths, stale skill/instruction references, inaccurate CI claims, out-of-date structure trees). +description: Build, preview, and verify the workshop site. Use before committing or updating a PR, when checking rendered content or links, or when reviewing structural drift. Derive affected routes and locales from the current source and configuration rather than fixed page counts. --- # Build and verify the docs site -The workshop content is plain Markdown in the repo-root `docs/` directory; the Astro + Starlight site that publishes it lives in `website/` (sourcing content via the loader's `base: '../docs'`). This skill is the single source of truth for **how to build, preview, and verify** that site. The instruction files (`.github/instructions/*`, `.github/copilot-instructions.md`) describe *what content should look like*; this skill describes *how to run the tooling*. +Lesson source lives in `docs/`; the Astro + Starlight publisher lives in `website/`. This skill owns the build and verification procedure. Other authoring guidance should link here instead of repeating commands or expected output totals. -Run every command from the **repo root** unless a step says otherwise. - -## When to use - -Trigger this skill whenever you: - -- are about to build the site (`npm run build`) or start the dev server, -- need to preview content locally, -- are running the pre-commit / pre-PR verification pass on any change under `docs/` (content) or `website/` (tooling), -- want to confirm the page-count invariant or check links, -- are about to open or update a PR that touches `docs/` or `website/`. - -For an optional **deeper, browser-based** pass that confirms pages actually render (console errors, broken images, mounted components), use the [`validate-site-playwright`](../validate-site-playwright/SKILL.md) skill after the static checks here. +Run commands from the repository root. Use existing dependencies and tools; ask before installing anything missing. ## Local preview -The Astro dev server is the primary preview surface (hot reload): - ```bash -cd website && npm install && npm run dev +(cd website && npm run dev) ``` -Open . Lesson content lives in the repo-root `docs/` directory; the loader sources it via `base: '../docs'`, so no symlinks are required for preview. +Open the local URL printed by the server, using the base path configured in `website/astro.config.mjs`. Stop the server you started when finished. Do not reuse or stop an unrelated server. -## Verification sequence (run before every commit) +## Verification before committing -Run all three. Don't commit if any fails. - -### 1. Build (clean) +### 1. Type-check and build ```bash -cd website && rm -rf dist && npm run build +(cd website && npm run check:all && rm -rf dist && npm run build) ``` -### 2. Page-count invariant - -The workshop has 36 distinct route slugs. Starlight emits each route for the English root locale and the five configured localized routes, using English fallback content when a translation is unavailable. The built site therefore contains $36 \times 6 = 216$ workshop routes plus the one legacy redirect (`/shared/0-prereqs/`, authored as a full-HTML redirect page at `website/src/pages/shared/0-prereqs.astro`). The expected count is 217 `index.html` files when excluding the 404 page. Astro reports 218 HTML files because it includes the 404 page. - -```bash -# distinct route slugs in the English root locale (docs/README.md + docs//*.md) -find docs -maxdepth 2 -name '*.md' \ - ! -path 'docs/es-es/*' \ - ! -path 'docs/ja-jp/*' \ - ! -path 'docs/ko-kr/*' \ - ! -path 'docs/pt-br/*' \ - ! -path 'docs/zh-cn/*' | wc -l -# built pages (excludes the 404) -find website/dist -name index.html | grep -v 404 | wc -l -``` +Stop on failures and resolve them before committing. Read `website/package.json` when the available commands or their coverage are uncertain. -`built pages` should equal `(distinct route slugs × configured locales) + 1`. If the build emits **more** pages than that without a matching route or locale change, confirm localized content is directly under `docs//` rather than an extra parent directory, then check the underscore-directory exclude in `website/src/content.config.ts`; it is still needed so support directories such as `_images/` are not routed as pages. +### 2. Check affected pages -### 2b. Translations actually render (not silent English fallback) +Use the diff to select pages to inspect in `website/dist/`. Derive their expected routes from the source paths and `slug` frontmatter, the content loader in `website/src/content.config.ts`, and the base path and locales in `website/astro.config.mjs`. -The build and the page-count above are **blind to which content actually renders** — a mis-nested or wrongly-identified locale tree still emits 217 pages served from English fallback. Assert that a known translated page carries translated text and the right `lang` attribute: +- For added or changed lessons, confirm the expected output exists and contains the source title, headings, and changed content. Check that prompts and admonitions render rather than appearing as raw markup. +- For translated pages, compare the output with that locale's source and configured `lang`. English fallback is expected only where a translation is absent; a successful build alone does not prove a translation rendered. +- For renamed or removed pages, check navigation and references against the intended new routes. Check redirects only where they are intentionally provided. +- For changes to shared rendering, the loader, or locale configuration, expand inspection to the affected harnesses and locales. Confirm support assets are not published as lesson pages. -```bash -grep -o '[^<]*' website/dist/es-es/app/2-add-star-rating/index.html # Spanish title -grep -o 'lang="[^"]*"' website/dist/es-es/app/2-add-star-rating/index.html | head -1 # lang="es-ES" -``` +Do not maintain a fixed page count, list of lesson filenames, or expected translated title here. Inspect actual source-to-output behavior; adding a lesson or locale should not require editing this skill. If comprehensive route validation is needed, derive expectations from source and configuration rather than treating a matching total as proof. -The Spanish title should read `Lección 2 - Ejecutar tu primera sesión de agente`, not the English string. Spot-check a second locale (e.g. `ja-jp` -> `lang="ja-JP"`). +Use the [browser validation skill][browser-validation] when visual behavior, client-side rendering, console errors, or image loading needs a deeper check. -### 3. Link check (lychee, offline) +### 3. Check internal links -The site builds with `base=/copilot-workshops/`, so internal hrefs are absolute (`/copilot-workshops/foo/`). Symlink that prefix to `website/dist` so lychee can follow internal links: +Lychee checks the built HTML offline. The root below maps the site's `/copilot-workshops/` base path to the build; adjust that mapping if the configured base changes. Use a unique temporary directory so concurrent checks do not share it. ```bash -mkdir -p /tmp/lychee-root && ln -sfn "$PWD/website/dist" /tmp/lychee-root/copilot-workshops \ - && lychee --offline --no-progress --root-dir /tmp/lychee-root 'website/dist/**/*.html' +( +set -eu +link_root=$(mktemp -d) +trap 'rm -f "$link_root/copilot-workshops"; rmdir "$link_root"' EXIT +ln -s "$PWD/website/dist" "$link_root/copilot-workshops" +lychee --offline --no-progress --root-dir "$link_root" 'website/dist/**/*.html' +) ``` -Lychee runs offline and won't catch broken **external** GitHub URLs. When you change absolute `https://github.com/...` links, click through them manually. - -## What CI enforces vs. what is local-only - -`.github/workflows/pages.yml` runs on PRs and on push to `main`. It runs **only**: - -1. `npm ci` -2. `npm run build` (Astro build) — must succeed -3. lychee offline link check against `website/dist/` — must pass +Resolve broken internal links and images before committing. Offline checks do not verify external URLs; open changed external links separately and confirm the intended destination. Report any checks that remain blocked. -After a push to `main`, `pages.yml` deploys `website/dist` to GitHub Pages. Browser validation and content-alignment analysis are separate optional/safety-net workflows, not part of the Pages build job. +## Consistency before updating a PR -## PR-time consistency pass +- Search for references to paths, names, or conventions changed by the diff, including repository guidance and navigation. Update affected references without rewriting unrelated documentation. +- For duplicated lesson content, use the [content alignment skill][content-alignment] to identify parallel passages and translations that may need matching changes. +- Keep descriptions of build commands and CI behavior consistent with `website/package.json` and `.github/workflows/pages.yml`. Those files, not copied prose, define what runs. -The build and link check above catch *mechanical* breakage. They do **not** catch *structural drift* — prose and reference material that silently falls out of sync when files move or conventions change. Before opening or updating a PR, make a consistency pass over everything your change touched: +The Pages workflow runs type checks, the build, and offline link validation; deployment is restricted to pushes to `main`. Source-to-output inspection, browser validation, and content-alignment review are separate from that build job. Consult the workflow for its current triggers and steps. -- **Renamed or moved a file or folder?** Grep the whole repo for the old path and update every hit — `.md`, instruction files, skills, and the repository-structure trees in `README.md`, `docs/README.md`, `website/README.md`, `AUTHORING.md`, and `.github/copilot-instructions.md`. Example: when `images/` became `_images/`, every `../images/...` reference and every structure tree had to change. -- **Added or removed a skill, agent, instruction file, or workflow?** Grep for references to the old name and remove them. Add a new `.github/instructions/*.instructions.md` file to the **Deeper conventions** list in `AUTHORING.md`, and to the structure block in `.github/copilot-instructions.md` if it's structural. -- **Changed duplicated lesson prose?** Run the `check-content-alignment` skill to identify other inline copies that need the same update. The `.github/workflows/content-alignment.md` agentic workflow runs the same analysis on PRs as a safety net. -- **Described what CI does anywhere?** Confirm it matches `.github/workflows/pages.yml`, which runs the build and the lychee link check. -- **Changed the build or verify steps?** This skill is the single source of truth. `README.md`, `AUTHORING.md`, and `CONTRIBUTING.md` should *point here*, not re-document the commands. Keep any summary in those files consistent with this skill. -- **Repository-structure trees** in `README.md`, `docs/README.md`, `website/README.md`, `AUTHORING.md`, and `.github/copilot-instructions.md` should all reflect the real tree. If you add or rename a top-level content directory, update all of them. -- **Page-count invariant** (section 2 above) should still hold after the build. - -When in doubt, `grep -rn "" --include='*.md' .` (excluding `node_modules` and `website/dist`) is the fastest way to surface stale references. - -## Quick reference - -```bash -# from repo root -cd website && rm -rf dist && npm run build && cd .. -mkdir -p /tmp/lychee-root && ln -sfn "$PWD/website/dist" /tmp/lychee-root/copilot-workshops \ - && lychee --offline --no-progress --root-dir /tmp/lychee-root 'website/dist/**/*.html' -``` +[browser-validation]: ../validate-site-playwright/SKILL.md +[content-alignment]: ../check-content-alignment/SKILL.md diff --git a/.github/skills/check-content-alignment/SKILL.md b/.github/skills/check-content-alignment/SKILL.md index 9fbbaf54..d8a1cd1a 100644 --- a/.github/skills/check-content-alignment/SKILL.md +++ b/.github/skills/check-content-alignment/SKILL.md @@ -1,95 +1,49 @@ --- name: check-content-alignment -description: Find workshop lessons that should change alongside an edit. Scans a diff (staged, unstaged, or a branch range) of the Copilot Workshops docs, extracts what changed, then searches the rest of docs/** for duplicated or parallel passages that now risk drifting out of sync — prose that used to be a shared partial and is now copied across pages, the same concept taught across the VS Code / CLI / App / Cloud harnesses, and cross-references to the changed page. Use after editing lesson content under docs/, before committing or opening a PR, or whenever asked to "check content alignment", "find related content to update", "what else should change", or "check for drift". Reports candidate files with line ranges and rationale; it does NOT edit content. +description: Find workshop content that may need matching changes after a lesson edit. Use for content drift reviews before committing or updating a PR. Discover duplicated passages, parallel concepts, translations, and cross-references from the diff and current content tree. Report actionable candidates without editing files. --- # Check content alignment -This workshop is **pure Markdown** with no partials. Prose that is conceptually shared is **duplicated inline** across lessons instead of single-sourced. That makes editing simpler but removes the partials system's guarantee that one edit updates every copy. This skill is the replacement guardrail: given a change, it finds the *other* places that should probably change too, so duplicated copies don't drift apart. +Lessons repeat concepts, prompts, and instructions across harnesses and translations. Given a content change, find other passages that may now be inconsistent. This skill is advisory and read-only: recommend changes, but do not edit files or publish comments unless separately requested. -It is **advisory and read-only** — it surfaces candidates and rationale for a human (or agent) to act on. It never edits lesson content. +## 1. Establish the review scope -Run every command from the **repo root** unless a step says otherwise. - -## When to use - -- After editing one or more lessons under `docs/**`, before committing or opening a PR. -- When asked to "check content alignment", "find related content to update", "what else should I change", or "check for drift". -- As the local counterpart to the **content-alignment agentic workflow** (`.github/workflows/content-alignment.md`), which runs this same analysis automatically on pull requests and comments its findings. Run the skill locally to catch drift *before* you push. - -## The drift surface (what to look for) - -There are three recurring kinds of duplication in this repo. Most alignment gaps fall into one of them: - -1. **Formerly-shared callouts and steps.** Several short callouts and exercise steps used to live in `_shared/` and were imported into many lessons. They are now copied verbatim into each consumer. When you change one copy, the others usually need the same change. Examples of high-fan-out prose to watch: - - The "start Copilot CLI / **Allow all**" approval callout (was used across several CLI/app pages). - - The "**Approve and run workflows**" step (appears in `cloud/5-iterating.md` and `vscode/6-iterating.md`). - - Shared prerequisites, MCP-setup, and recap blurbs. -2. **Parallel concepts across harnesses.** The same idea is taught up to four times, once per harness: `cli/`, `vscode/`, `app/`, and `cloud/`. A conceptual change (how MCP works, what a custom instruction is, how an agent proposes changes, the description of the Tailspin Toys demo app) often needs the same correction in the sibling lessons of the other harnesses. -3. **Cross-references and shared facts.** Reference-style links to a renamed/retitled lesson, lesson numbers in prose, the published URL shape (`/cli/3-generating-code/`), the demo-app repo URL (`github.com/github-samples/tailspin-toys/...`), tool/library names, and screenshots referenced from multiple pages. - -## Procedure - -### 1. Get the diff - -Pick the comparison that matches what you're reviewing: +Use the comparison requested by the user. For in-progress work, inspect both unstaged and staged changes: ```bash -# Unstaged + staged working-tree changes (default: reviewing your own in-progress edits) git diff -- docs git diff --staged -- docs - -# A whole branch vs. the base it will merge into (reviewing a PR-sized change) -git fetch origin -git diff origin/main...HEAD -- docs +git ls-files --others --exclude-standard -- docs ``` -Limit to `docs` — content drift is the only thing this skill reasons about. - -### 2. Extract the semantic changes - -For each changed lesson, summarize *what actually changed in meaning*, not just which lines moved: - -- Reworded or corrected **facts** (versions, library names, counts, behavior, UI labels, the demo-app description). -- Edited **callouts / steps** that look like formerly-shared prose (see drift surface #1). -- New, renamed, or removed **headings, lesson titles, or files** (these invalidate cross-references). -- Changed **links**, lesson numbers, URLs, or image references. +Read relevant untracked files as well; they do not appear in a normal diff. For a PR or branch review, identify its actual base ref and compare from the merge base to the head. Do not assume every PR targets `main`. Fetch the relevant refs when needed. -Ignore pure formatting (wrapping, whitespace) and changes that are genuinely page-specific. +If there are no lesson changes under `docs/`, report that this content review does not apply. Repository guidance and tooling belong to the [build and verification consistency pass][build-verification]. -### 3. Find the parallel copies +## 2. Identify changes in meaning -For each semantic change, search the rest of the content for passages that should match. Use the changed wording (and its near-synonyms) as the query, and always search the sibling harnesses. +Extract changed facts, requirements, prompts, steps, UI labels, titles, navigation, links, and image references. Ignore formatting-only changes. -```bash -# Find other pages that carry the same callout/step/sentence (quote a distinctive phrase) -grep -rn "Approve and run workflows" docs --include='*.md' - -# Find the parallel lesson in the other harnesses (same concept, different harness) -grep -rln "custom instruction" docs/cli docs/vscode docs/app docs/cloud --include='*.md' - -# Find cross-references to a page you renamed/retitled -grep -rn "2-custom-instructions" docs --include='*.md' -``` +Use the current content tree and locale configuration in `website/astro.config.mjs` to discover related harnesses and translations. Do not maintain a list of lesson numbers, filenames, locale counts, or historical shared passages in this skill. -Exclude the file(s) you already changed from the candidate list. +## 3. Find and inspect related passages -### 4. Report candidates +Search across `docs/` using distinctive old and new phrases, technical identifiers, link targets, and related concepts from the diff. Inspect surrounding text before reporting a match. -Produce a concise, actionable report. For each candidate, give: +- **Repeated content:** copied prompts, instructions, setup steps, and facts that should still agree. +- **Parallel concepts:** the same topic taught through another harness. Preserve intentional differences in UI, permissions, setup, and workflow. +- **Translations:** corresponding localized lessons. Match by content paths and meaning; an English text search alone will miss translated prose. +- **References:** links, titles, lesson numbers, and shared image references affected by a rename or content change. -- **File** and approximate **line range** (or heading) of the passage that may need updating. -- **Why** it's a candidate: which change it parallels, and which drift category (shared-copy, cross-harness, or cross-reference). -- **Suggested action**: the specific edit to mirror, or "review — may be intentionally different." +Check already-modified related files too; being included in the diff does not prove the matching change is complete. Exclude passages whose alignment is already resolved, not every file that was touched. -Group by the source change. Lead with the highest-confidence matches (verbatim shared-copy duplicates) and separate the lower-confidence cross-harness "you may also want to mirror this" items. Be explicit when a parallel passage is *intentionally* harness-specific and should NOT be changed. +## 4. Report actionable candidates -### 5. Hand off +Group findings by the source change. For each candidate, give the file and line range or heading, explain the inconsistency, and suggest the matching edit or a review of an intentional difference. -This skill stops at recommendations. If the user wants the aligned edits made, treat that as a follow-up content-editing task and apply the changes with the normal authoring conventions, then re-run **build-and-verify-docs**. +Lead with confirmed mismatches. Clearly separate uncertain candidates from required corrections, and do not recommend identical wording where behavior legitimately differs. If no likely drift remains, say so briefly and identify the scope reviewed. -## Boundaries +Stop at recommendations. Apply edits only as a separately authorized content-editing task, then verify them using the normal authoring process. -- **Read-only.** Never edit lesson content as part of this skill. -- **docs content only.** Don't flag instruction files, skills, or other repo docs — only `docs/**`. -- **Advisory, not a gate.** False positives are expected; the human decides. Prefer surfacing a borderline candidate (clearly marked low-confidence) over missing a real drift. +[build-verification]: ../build-and-verify-docs/SKILL.md diff --git a/.github/skills/make-repo-contribution/SKILL.md b/.github/skills/make-repo-contribution/SKILL.md index 30f8ea91..5e8d93ed 100644 --- a/.github/skills/make-repo-contribution/SKILL.md +++ b/.github/skills/make-repo-contribution/SKILL.md @@ -39,7 +39,7 @@ The authoritative sources are [`AUTHORING.md`](../../../AUTHORING.md), [`CONTRIB Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> ``` - **Commits:** small, logically grouped, with short messages. -- **Verify before a PR** with the [`build-and-verify-docs`](../build-and-verify-docs/SKILL.md) skill (build, page-count check, lychee link check); CI runs the build + link check. An optional deeper browser pass lives in [`validate-site-playwright`](../validate-site-playwright/SKILL.md). +- **Verify before a PR** with the [`build-and-verify-docs`](../build-and-verify-docs/SKILL.md) skill. It owns the current verification procedure and explains CI coverage. An optional deeper browser pass lives in [`validate-site-playwright`](../validate-site-playwright/SKILL.md). ## Using existing guidelines diff --git a/.github/skills/validate-site-playwright/SKILL.md b/.github/skills/validate-site-playwright/SKILL.md index b611531d..f0b22f9f 100644 --- a/.github/skills/validate-site-playwright/SKILL.md +++ b/.github/skills/validate-site-playwright/SKILL.md @@ -5,7 +5,7 @@ description: Render-validate the built Copilot Workshops site in a real browser # Validate the built site with Playwright -`build-and-verify-docs` checks the site *statically* — it builds, confirms the page-count invariant, and link-checks the HTML with lychee. It never opens a page in a browser, so it cannot see runtime failures: console/hydration errors, images that 404 at load time, or rendered Markdown that looks wrong. +`build-and-verify-docs` checks the site *statically* — it type-checks, builds, inspects affected output against its source, and link-checks the HTML with lychee. Browser checks complement that evidence by finding runtime failures: console/hydration errors, images that fail to load, or visual rendering problems. This skill is the **optional, deeper, browser-based pass**. It drives the **Playwright MCP server** against a local preview of the built site. It is **interactive/local only** — CI (`pages.yml`) has no browser step, so this is never a merge gate. Run it before a PR that changes how pages render (new site-shell components, image-heavy lessons, layout changes), or whenever you want to confirm the real rendered output. diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 2e853b76..3f1cffaf 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -2,7 +2,7 @@ name: Build and deploy workshop site # Builds the Astro + Starlight site under `website/` (sourcing lesson Markdown from # `docs/`) and deploys to GitHub Pages on pushes to main. Every push and pull -# request runs a build-only validation pass plus a link check, so the build can +# request runs type-check, build, and offline link validation, so this job can # serve as a required status check on any PR. on: diff --git a/AUTHORING.md b/AUTHORING.md index bd8cefb1..fd226b81 100644 --- a/AUTHORING.md +++ b/AUTHORING.md @@ -41,7 +41,7 @@ copilot-workshops/ Body starts here. ``` Only `title` is required; it becomes the H1 and the page title. Don't add a body H1 — Starlight renders the title automatically. -3. **Write the body.** Use Markdown and GitHub admonition syntax (`> [!NOTE]`) for callouts. See **Style essentials** below. +3. **Write the body.** Follow the [lesson pattern](#lesson-pattern), using Markdown and GitHub admonition syntax (`> [!NOTE]`) for callouts. See **Style essentials** below. 4. **Add prev/next navigation.** Define `[previous-lesson]` and `[next-lesson]` reference links at the bottom of the page, pointing at the adjacent lessons in the same path: ```markdown [previous-lesson]: ../2-custom-instructions/ @@ -60,7 +60,19 @@ copilot-workshops/ ``` The first lesson in a path omits `[previous-lesson]`; the last omits `[next-lesson]`. 5. **Register in the sidebar.** Open `website/astro.config.mjs` and add an entry to the appropriate `items: []` block. The sidebar is *manually* maintained — order in the file is the order learners see. -6. **Preview and verify, then open a PR.** Preview locally and run the verification sequence before committing — see [Building and verifying](#building-and-verifying) below. CI runs the Astro build and the lychee link check; both must pass. +6. **Preview and verify, then open a PR.** Preview locally and run the verification sequence before committing — see [Building and verifying](#building-and-verifying) below. CI runs type checks, the Astro build, and the lychee link check; all must pass. + +### Lesson pattern + +Use the same teaching flow across harnesses without forcing identical exercises or wording: + +1. **Introduction and objectives.** Connect to the previous work and give a short list of what the learner will do. +2. **Scenario.** Before the technical explanation or steps, use a `## Scenario` section to explain the Tailspin Toys need and why this task matters. +3. **Concepts and guided work.** Explain new concepts where needed, then use task-specific headings and ordered steps. Keep prompts next to the actions they support. +4. **Review and verification.** Tell the learner what to inspect, what evidence to expect, and how to handle failures before moving on. This can be part of the guided steps rather than a separate section. +5. **Summary and next steps.** Briefly connect the completed work to the next module, making the branch, session, checkpoint, or PR handoff clear where relevant. Add a Resources section when useful. + +Prerequisite modules use setup goals and a readiness check instead of an artificial feature scenario. Final recap modules review outcomes and further learning rather than introduce a new task. Landing pages remain workshop overviews. Preserve harness-specific controls and workflow boundaries, and mirror structural changes in existing translations. ### Landing pages (folder `README.md`) @@ -98,25 +110,9 @@ Because copied prose can drift, run the `check-content-alignment` skill when you ## Building and verifying -Before opening a PR, preview the site and run the full verification sequence. The canonical commands live in the [`build-and-verify-docs`](./.github/skills/build-and-verify-docs/SKILL.md) skill — the summary below mirrors it. - -**Preview** with the Astro dev server (hot reload): - -```bash -cd website -npm install -npm run dev -``` - -The site runs at . - -**Verify** before committing: - -1. **Build** — `cd website && rm -rf dist && npm run build`. Must succeed. -2. **Page-count invariant** — Starlight emits 36 workshop routes for English and each of the five configured locales, then adds the legacy redirect. This equals 217 built `index.html` pages when excluding the 404 page; the build reports 218 HTML files including the 404 page. -3. **Link check** — lychee (offline) against the built `website/dist/`. Catches broken internal links/images. +Use the [`build-and-verify-docs`](./.github/skills/build-and-verify-docs/SKILL.md) skill for preview commands and pre-commit verification: type checks, a clean build, inspection of affected pages and translations, and offline link checking. Expected output comes from the current source and site configuration, not a fixed page total. -**What CI enforces vs. what you run locally:** CI (`pages.yml`) runs the **build** and the **lychee** link check on every PR. It does not run browser validation or the content-alignment agentic workflow as part of the Pages build job. After merge to `main`, `pages.yml` deploys the site to GitHub Pages. +**What CI enforces vs. what you run locally:** CI (`pages.yml`) runs **`check:all`**, the **build**, and the **lychee** link check on every PR. It does not run browser validation or the content-alignment agentic workflow as part of the Pages build job. After merge to `main`, `pages.yml` deploys the site to GitHub Pages. **Consistency pass.** When a change renames a file or folder, adds or removes a skill or instruction file, touches duplicated prose, or changes how the build works, also sweep for stale references — the structure trees and cross-doc pointers in `README.md`, `AUTHORING.md`, and `.github/copilot-instructions.md` aren't checked by the Astro build. The [`build-and-verify-docs`](./.github/skills/build-and-verify-docs/SKILL.md) skill has the full checklist, and the `check-content-alignment` skill plus `.github/workflows/content-alignment.md` help catch prose that needs aligned updates. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64292b5f..72f0cbec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,10 +24,11 @@ If you want to **author or edit content**, start with [`AUTHORING.md`](./AUTHORI CI (`pages.yml`) must be green on your PR. It runs: +- **Type checks** — `npm run check:all` (Astro and TypeScript checks). - **`pages.yml` build** — `npm run build` (Astro site build). - **Lychee** — offline link check of the built `website/dist/`. -Before you push, run the full local verification sequence described in [AUTHORING.md → Building and verifying](./AUTHORING.md#building-and-verifying): clean build, page-count check, and lychee link check. +Before you push, follow the [`build-and-verify-docs`](./.github/skills/build-and-verify-docs/SKILL.md) skill for type checks, a clean build, inspection of affected pages, and offline link checking. ## Commit messages diff --git a/README.md b/README.md index 38c04eb2..f86ad396 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ The site runs at . ## Verification -Before opening a PR, build the site and run the full verification sequence — clean build, page-count check, and offline link check (lychee). The canonical commands live in **[AUTHORING.md → Building and verifying](./AUTHORING.md#building-and-verifying)** and the [`build-and-verify-docs`](./.github/skills/build-and-verify-docs/SKILL.md) skill. CI (`pages.yml`) runs the build and the lychee link check. +Before opening a PR, follow the [`build-and-verify-docs`](./.github/skills/build-and-verify-docs/SKILL.md) skill for type checks, a clean build, inspection of affected pages, and offline link checking. See [AUTHORING.md](./AUTHORING.md#building-and-verifying) for authoring context. ## License From 0d4024fca2ab691b2c2e2573889c7039028bc6a3 Mon Sep 17 00:00:00 2001 From: Christopher Harrison Date: Mon, 14 Sep 2026 15:12:35 -0700 Subject: [PATCH 2/3] chore: script deterministic workshop verification Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/skills/README.md | 2 +- .github/skills/build-and-verify-docs/SKILL.md | 55 +++-- .../build-and-verify-docs/scripts/capture.mjs | 85 +++++++ .../build-and-verify-docs/scripts/content.mjs | 224 ++++++++++++++++++ .../build-and-verify-docs/scripts/verify.mjs | 126 ++++++++++ .../scripts/verify.test.mjs | 198 ++++++++++++++++ website/astro.config.mjs | 2 + website/package-lock.json | 1 + website/package.json | 1 + website/src/content.config.ts | 5 +- 10 files changed, 673 insertions(+), 26 deletions(-) create mode 100644 .github/skills/build-and-verify-docs/scripts/capture.mjs create mode 100644 .github/skills/build-and-verify-docs/scripts/content.mjs create mode 100644 .github/skills/build-and-verify-docs/scripts/verify.mjs create mode 100644 .github/skills/build-and-verify-docs/scripts/verify.test.mjs diff --git a/.github/skills/README.md b/.github/skills/README.md index 3890c014..84f063e5 100644 --- a/.github/skills/README.md +++ b/.github/skills/README.md @@ -10,7 +10,7 @@ These encode conventions unique to this Astro + Starlight workshop content repo. | Skill | What it does | Use it when | |---|---|---| -| [`build-and-verify-docs`](./build-and-verify-docs/SKILL.md) | Canonical preview and verification process: type checks, clean build, affected-page inspection, offline links, and a structural consistency pass. | Building, previewing, or verifying the site; before every commit/PR. | +| [`build-and-verify-docs`](./build-and-verify-docs/SKILL.md) | Bundled scripts for type checks, a clean build, source-derived output verification, and offline links; AI guidance for semantic, visual, and structural review. | Building, previewing, or verifying the site; before every commit/PR. | | [`check-content-alignment`](./check-content-alignment/SKILL.md) | Uses the diff and current content tree to find inconsistent repeated passages, parallel concepts, translations, and cross-references. Reports candidates without editing content. | After lesson edits or when reviewing content drift. | | [`validate-site-playwright`](./validate-site-playwright/SKILL.md) | Optional deeper **browser** QA: drives the Playwright MCP server against a local preview to confirm pages render, catch console/hydration errors, find broken images, and confirm Starlight components mounted. | A deeper render/visual pass before a PR that changes how pages render. Complements (doesn't replace) `build-and-verify-docs`. | diff --git a/.github/skills/build-and-verify-docs/SKILL.md b/.github/skills/build-and-verify-docs/SKILL.md index d5b4e446..e1992fd9 100644 --- a/.github/skills/build-and-verify-docs/SKILL.md +++ b/.github/skills/build-and-verify-docs/SKILL.md @@ -7,7 +7,7 @@ description: Build, preview, and verify the workshop site. Use before committing Lesson source lives in `docs/`; the Astro + Starlight publisher lives in `website/`. This skill owns the build and verification procedure. Other authoring guidance should link here instead of repeating commands or expected output totals. -Run commands from the repository root. Use existing dependencies and tools; ask before installing anything missing. +Use the bundled Node.js entry point for deterministic checks. It derives the repository from its own location, so it also works when invoked by absolute path from another directory. The examples below use Bash from the repository root. Use existing dependencies and tools; ask before installing anything missing. ## Local preview @@ -19,42 +19,51 @@ Open the local URL printed by the server, using the base path configured in `web ## Verification before committing -### 1. Type-check and build +### Run the deterministic checks ```bash -(cd website && npm run check:all && rm -rf dist && npm run build) +node .github/skills/build-and-verify-docs/scripts/verify.mjs ``` -Stop on failures and resolve them before committing. Read `website/package.json` when the available commands or their coverage are uncertain. +The default `all` mode runs the existing `website/package.json` scripts (`check:all`, then `build`), safely cleans `website/dist`, verifies every source-derived page, and runs offline Lychee. It preserves subprocess output and exit codes. Missing tools, missing artifacts, unsafe configuration, unsupported content, or mismatches fail explicitly; stop and resolve failures before committing. -### 2. Check affected pages +The scripts use the actual content loader and Astro integration hooks to capture parsed entries, generated IDs/slugs, resolved locales/base path, and intentional static routes. They do not parse configuration with regular expressions or maintain a parallel file inventory. The capture hooks are inactive during ordinary builds. Verification caches stay under the worktree's `website/.astro/`, not shared dependencies. -Use the diff to select pages to inspect in `website/dist/`. Derive their expected routes from the source paths and `slug` frontmatter, the content loader in `website/src/content.config.ts`, and the base path and locales in `website/astro.config.mjs`. +The deterministic checks cover: -- For added or changed lessons, confirm the expected output exists and contains the source title, headings, and changed content. Check that prompts and admonitions render rather than appearing as raw markup. -- For translated pages, compare the output with that locale's source and configured `lang`. English fallback is expected only where a translation is absent; a successful build alone does not prove a translation rendered. -- For renamed or removed pages, check navigation and references against the intended new routes. Check redirects only where they are intentionally provided. -- For changes to shared rendering, the loader, or locale configuration, expand inspection to the affected harnesses and locales. Confirm support assets are not published as lesson pages. +- Expected versus actual HTML routes, including missing pages and unexpected stale/support pages. Static extras such as redirects and error pages come from Astro's resolved routes. +- Localized versus default-locale fallback sources, document language, title/H1, ordered headings, normalized visible prose and image alternative text, and exact code-block contents including indentation and blank lines. +- Unconverted GitHub admonition markers outside code examples. +- Offline internal links and images using a unique temporary root mapped to the configured base path. Temporary directories are removed on success or failure. -Do not maintain a fixed page count, list of lesson filenames, or expected translated title here. Inspect actual source-to-output behavior; adding a lesson or locale should not require editing this skill. If comprehensive route validation is needed, derive expectations from source and configuration rather than treating a matching total as proof. +Markdown/frontmatter parsing uses the declared `@astrojs/markdown-remark` public API; full-document HTML parsing uses the directly declared `hast-util-from-html` dependency. Code extraction supports plain code blocks and the site's Expressive Code output; unsupported line structures fail rather than guessing. -Use the [browser validation skill][browser-validation] when visual behavior, client-side rendering, console errors, or image loading needs a deeper check. +### Select a narrower operation -### 3. Check internal links +Use `build` for type checks, a clean build, and source-to-output verification without Lychee. Use `links` to recheck an existing build; it runs the package's Astro sync command to resolve current configuration, but does not rebuild or certify source/output freshness. -Lychee checks the built HTML offline. The root below maps the site's `/copilot-workshops/` base path to the build; adjust that mapping if the configured base changes. Use a unique temporary directory so concurrent checks do not share it. +```bash +node .github/skills/build-and-verify-docs/scripts/verify.mjs build +node .github/skills/build-and-verify-docs/scripts/verify.mjs links +``` + +These scripts target the current static Starlight site with directory-format HTML in `website/dist`. MDX, alternate Markdown processors, unknown prerendered dynamic routes, and manual/domain locale routing fail explicitly until supported with tests. They are tested on macOS/POSIX; native Windows is not supported. They do not install tools, start servers, manage ports, or change running processes. + +### Maintain the scripts + +Run the bundled tests after changing verification logic: ```bash -( -set -eu -link_root=$(mktemp -d) -trap 'rm -f "$link_root/copilot-workshops"; rmdir "$link_root"' EXIT -ln -s "$PWD/website/dist" "$link_root/copilot-workshops" -lychee --offline --no-progress --root-dir "$link_root" 'website/dist/**/*.html' -) +node --test .github/skills/build-and-verify-docs/scripts/verify.test.mjs ``` -Resolve broken internal links and images before committing. Offline checks do not verify external URLs; open changed external links separately and confirm the intended destination. Report any checks that remain blocked. +Tests include deliberate mismatches and real fixture builds with added lessons, slugs, translations, locales, changed base paths, filenames and directories containing spaces, and another working directory. The fixtures reuse installed dependencies without installing packages and remove their own temporary files. + +## AI review after the scripts pass + +Use judgment for semantic accuracy, teaching flow, translation quality, intentional harness differences, and whether navigation or a redirect leads to the right next task. Scripts verify what is present, not whether the lesson is pedagogically correct. + +Use the [browser validation skill][browser-validation] when layout, styling, client-side behavior, console errors, or visual image loading requires review. The existing plain-div admonition rendering is not fixed or certified by the raw-marker check; styling still needs browser judgment. Offline checks do not verify external destinations; open changed external links separately and confirm they are the intended resource. Report blocked checks honestly. ## Consistency before updating a PR @@ -62,7 +71,7 @@ Resolve broken internal links and images before committing. Offline checks do no - For duplicated lesson content, use the [content alignment skill][content-alignment] to identify parallel passages and translations that may need matching changes. - Keep descriptions of build commands and CI behavior consistent with `website/package.json` and `.github/workflows/pages.yml`. Those files, not copied prose, define what runs. -The Pages workflow runs type checks, the build, and offline link validation; deployment is restricted to pushes to `main`. Source-to-output inspection, browser validation, and content-alignment review are separate from that build job. Consult the workflow for its current triggers and steps. +The Pages workflow runs type checks, the build, and offline link validation; deployment is restricted to pushes to `main`. The bundled source-to-output verification, browser validation, and content-alignment review are separate from that build job. Consult the workflow for its current triggers and steps. [browser-validation]: ../validate-site-playwright/SKILL.md [content-alignment]: ../check-content-alignment/SKILL.md diff --git a/.github/skills/build-and-verify-docs/scripts/capture.mjs b/.github/skills/build-and-verify-docs/scripts/capture.mjs new file mode 100644 index 00000000..28551aa2 --- /dev/null +++ b/.github/skills/build-and-verify-docs/scripts/capture.mjs @@ -0,0 +1,85 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Only the verifier sets this variable, pointing at its private temporary directory. +async function record(name, value, root) { + const directory = process.env.DOCS_VERIFY_CAPTURE; + if (!directory) return; + const owner = await readFile(join(directory, 'owner'), 'utf8'); + if (owner !== fileURLToPath(root)) throw new Error('Verification capture belongs to a different site root.'); + await writeFile(join(directory, name), JSON.stringify(value)); +} + +/** @returns {import('astro').AstroIntegration} */ +export function verificationIntegration() { + return { + name: 'workshop-verification', + hooks: process.env.DOCS_VERIFY_CAPTURE ? { + 'astro:config:setup': ({ config, updateConfig }) => { + // Never write caches into node_modules, which may be shared read-only. + updateConfig({ + cacheDir: new URL('./.astro/verification-cache/', config.root), + vite: { cacheDir: fileURLToPath(new URL('./.astro/verification-vite/', config.root)) }, + }); + }, + 'astro:config:done': async ({ config }) => { + if (config.markdown.processor.name !== 'unified') { + throw new Error(`Unsupported Markdown processor: ${config.markdown.processor.name}`); + } + await record('config.json', { + root: config.root.href, + outDir: config.outDir.href, + base: config.base, + format: config.build.format, + output: config.output, + i18n: config.i18n, + markdown: { + gfm: config.markdown.processor.options?.gfm ?? config.markdown.gfm, + smartypants: config.markdown.processor.options?.smartypants ?? config.markdown.smartypants, + }, + }, config.root); + }, + 'astro:routes:resolved': async ({ routes }) => { + const directory = process.env.DOCS_VERIFY_CAPTURE; + const root = new URL(JSON.parse(await readFile(join(directory, 'config.json'), 'utf8')).root); + await record('routes.json', routes.map((route) => ({ + pathname: route.pathname, + pattern: route.pattern, + entrypoint: route.entrypoint, + params: route.params, + type: route.type, + isPrerendered: route.isPrerendered, + })), root); + }, + } : {}, + }; +} + +/** + * Capture the real loader's parsed entries, not a second implementation of its globs or slugging. + * @param {import('astro/loaders').Loader} loader + * @returns {import('astro/loaders').Loader} + */ +export function captureLoader(loader) { + if (!process.env.DOCS_VERIFY_CAPTURE) return loader; + return { + ...loader, + async load(context) { + const errors = []; + const logger = Object.create(context.logger); + logger.error = (message) => { + errors.push(message); + context.logger.error(message); + }; + await loader.load({ + ...context, + logger, + }); + if (errors.length) throw new Error(`Content loader failed: ${errors.join('; ')}`); + await record('sources.json', [...context.store.values()].map(({ id, data, body, filePath }) => ({ + id, data, body, filePath, + })), context.config.root); + }, + }; +} diff --git a/.github/skills/build-and-verify-docs/scripts/content.mjs b/.github/skills/build-and-verify-docs/scripts/content.mjs new file mode 100644 index 00000000..cc832275 --- /dev/null +++ b/.github/skills/build-and-verify-docs/scripts/content.mjs @@ -0,0 +1,224 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { readFile, readdir } from 'node:fs/promises'; +import { join, resolve, relative, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export async function parsers(website) { + const require = createRequire(pathToFileURL(join(website, 'package.json'))); + const markdown = await import(require.resolve('@astrojs/markdown-remark')); + const { fromHtml } = await import(require.resolve('hast-util-from-html')); + return { ...markdown, fromHtml }; +} + +export function elements(node, predicate) { + const result = []; + function visit(current) { + if (current.type === 'element' && predicate(current)) result.push(current); + for (const child of current.children ?? []) visit(child); + } + visit(node); + return result; +} + +const hasClass = (node, name) => node.properties?.className?.includes(name); +const words = (value) => value.replace(/\s+/gu, ' ').trim(); +const lines = (value) => value.replace(/\r\n?/g, '\n'); + +function text(node) { + if (node.type === 'text') return node.value; + if (node.tagName === 'br') return '\n'; + if (node.tagName === 'img') return node.properties?.alt ?? ''; + // Starlight adds accessible permalink labels which are not part of source headings. + if (node.tagName === 'a' && hasClass(node, 'sl-anchor-link')) return ''; + return (node.children ?? []).map(text).join(''); +} + +function headings(tree) { + return elements(tree, (node) => /^h[1-6]$/.test(node.tagName)) + .map((node) => [Number(node.tagName[1]), words(text(node))]); +} + +function prose(node, stripMarkers = false, inCode = false) { + if (['pre', 'style', 'script', 'svg'].includes(node.tagName) || hasClass(node, 'expressive-code') || + (node.tagName === 'a' && hasClass(node, 'sl-anchor-link'))) return ''; + if (node.type === 'text') return stripMarkers && !inCode + ? node.value.replace(/\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/g, '') : node.value; + if (node.tagName === 'img') return node.properties?.alt ?? ''; + const value = (node.children ?? []).map((child) => prose(child, stripMarkers, inCode || node.tagName === 'code')).join(''); + return ['p', 'div', 'li', 'td', 'th', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName) + ? ` ${value} ` : value; +} + +function codeBlocks(tree) { + return elements(tree, (node) => node.tagName === 'pre').map((pre) => { + const codes = elements(pre, (node) => node.tagName === 'code'); + assert.equal(codes.length, 1, 'Unsupported code block: expected one per
.');
+    const code = codes[0];
+    const expressiveLines = elements(code, (node) => hasClass(node, 'ec-line'));
+    if (expressiveLines.length) {
+      return expressiveLines.map((line) => {
+        const content = elements(line, (node) => hasClass(node, 'code'));
+        assert.equal(content.length, 1, 'Unsupported Expressive Code line structure.');
+        const value = text(content[0]);
+        // Expressive Code uses a single newline as the placeholder for an empty visual line.
+        if (value === '\n') return '';
+        assert(!value.includes('\n'), 'Unsupported multiline Expressive Code line.');
+        return value;
+      }).join('\n');
+    }
+    assert.equal(elements(code, (node) => ['div', 'table'].includes(node.tagName)).length, 0,
+      'Unsupported code renderer: cannot infer line breaks.');
+    return lines(text(code)).replace(/\n$/, '');
+  });
+}
+
+export function routeFile(id) {
+  const route = id === 'index' ? '' : id.replace(/\/index$/, '').normalize();
+  assert(!route.startsWith('/') && !route.includes('\\') && !/[?#%]/.test(route),
+    `Unsupported route slug: ${id}`);
+  assert(!route.split('/').some((part) => part === '.' || part === '..'), `Unsafe route slug: ${id}`);
+  return route ? `${route}/index.html` : 'index.html';
+}
+
+export function expectedPages(entries, i18n) {
+  assert(i18n && Array.isArray(i18n.locales) && i18n.locales.length, 'Missing resolved locale configuration.');
+  assert(i18n.routing !== 'manual' && !i18n.domains, 'Unsupported locale routing: manual routing or domains.');
+  const locales = i18n.locales.map((locale) => typeof locale === 'string'
+    ? { path: locale, lang: locale }
+    : { path: locale.path, lang: locale.codes[0] });
+  const defaultLocale = locales.find((locale) => locale.path === i18n.defaultLocale);
+  assert(defaultLocale, 'Default locale is not configured.');
+  if (!i18n.routing.prefixDefaultLocale) defaultLocale.path = '';
+  for (const locale of locales) {
+    assert(typeof locale.lang === 'string' && locale.lang.length, 'Locale requires a language tag.');
+    routeFile(locale.path);
+  }
+  const localized = (id) => locales.find((locale) =>
+    locale.path && (id === locale.path || id.startsWith(`${locale.path}/`))) ??
+    locales.find((locale) => locale.path === '');
+  const pages = new Map();
+  const add = (id, entry, locale, fallback) => {
+    const file = routeFile(id);
+    assert(!pages.has(file), `Duplicate output route: ${file}`);
+    pages.set(file, { entry, lang: locale.lang, fallback });
+  };
+  const published = entries.filter((entry) => !entry.data.draft);
+  assert(published.length, 'No published source entries were captured.');
+  for (const entry of published) {
+    assert(typeof entry.id === 'string' && typeof entry.data.title === 'string', 'Invalid captured source entry.');
+    const id = entry.id === 'index' ? '' : entry.id;
+    const locale = localized(id);
+    assert(locale, `Source does not belong to a configured locale: ${entry.id}`);
+    add(id, entry, locale, false);
+  }
+  for (const entry of published) {
+    const id = entry.id === 'index' ? '' : entry.id;
+    if (localized(id) !== defaultLocale) continue;
+    const suffix = defaultLocale.path ? id.slice(defaultLocale.path.length).replace(/^\//, '') : id;
+    for (const locale of locales) {
+      const target = [locale.path, suffix].filter(Boolean).join('/');
+      if (!pages.has(routeFile(target))) add(target, entry, locale, true);
+    }
+  }
+  return pages;
+}
+
+export function extraPages(routes) {
+  const extras = new Set();
+  let collectionRoute = false;
+  for (const route of routes) {
+    if (route.type === 'endpoint' || !route.isPrerendered) continue;
+    if (route.params.length) {
+      assert(route.pattern === '/[...slug]' &&
+        route.entrypoint.includes('@astrojs/starlight/routes/static/index.astro'),
+      `Unsupported dynamic page route: ${route.pattern} (${route.entrypoint})`);
+      collectionRoute = true;
+      continue;
+    }
+    assert(typeof route.pathname === 'string', `Missing static pathname: ${route.pattern}`);
+    const pathname = route.pathname.replace(/^\/|\/$/g, '');
+    // Astro emits its special 404/500 routes as files, regardless of build.format.
+    extras.add(['404', '500'].includes(pathname) ? `${pathname}.html` : routeFile(pathname));
+  }
+  assert(collectionRoute, 'The resolved Starlight collection route was not found.');
+  return extras;
+}
+
+export async function htmlFiles(directory) {
+  const files = [];
+  for (const entry of await readdir(directory, { withFileTypes: true })) {
+    assert(!entry.isSymbolicLink(), `Unexpected symlink in build output: ${join(directory, entry.name)}`);
+    if (entry.isDirectory()) {
+      for (const child of await htmlFiles(join(directory, entry.name))) files.push(`${entry.name}/${child}`);
+    } else if (entry.name.endsWith('.html')) files.push(entry.name);
+  }
+  return files;
+}
+
+export function compareRoutes(actual, pages, extras) {
+  const expected = new Set([...pages.keys(), ...extras]);
+  const found = new Set(actual);
+  const missing = [...expected].filter((file) => !found.has(file));
+  const unexpected = [...found].filter((file) => !expected.has(file));
+  assert(!missing.length && !unexpected.length,
+    `Route mismatch.\nMissing: ${missing.join(', ') || '(none)'}\nUnexpected: ${unexpected.join(', ') || '(none)'}`);
+}
+
+export async function sourceContent(entry, website, api, markdownOptions) {
+  assert(entry.filePath?.endsWith('.md'), `Unsupported source type: ${entry.filePath}; Markdown is required.`);
+  const file = resolve(website, entry.filePath);
+  const repo = resolve(website, '..');
+  assert(!relative(repo, file).startsWith(`..${sep}`), `Source escapes repository: ${file}`);
+  const parsed = api.parseFrontmatter(await readFile(file, 'utf8'));
+  assert.equal(parsed.frontmatter.title, entry.data.title, `Source title changed since capture: ${file}`);
+  assert.equal(lines(parsed.content).trim(), lines(entry.body).trim(), `Source body changed since capture: ${file}`);
+  const processor = await api.createMarkdownProcessor({ ...markdownOptions, syntaxHighlight: false });
+  // Omit fileURL so the public renderer retains , rather than build-time asset placeholders.
+  const rendered = await processor.render(parsed.content, { frontmatter: parsed.frontmatter });
+  const tree = api.fromHtml(rendered.code, { fragment: true });
+  return {
+    title: entry.data.title, headings: headings(tree), code: codeBlocks(tree),
+    prose: words(prose(tree, true)),
+  };
+}
+
+export function comparePage(html, expected, source, api, label) {
+  const tree = api.fromHtml(html);
+  const one = (tag) => {
+    const nodes = elements(tree, (node) => node.tagName === tag);
+    assert.equal(nodes.length, 1, `${label}: expected exactly one ${tag}`);
+    return nodes[0];
+  };
+  assert.equal(one('html').properties.lang, expected.lang, `${label}: document language`);
+  const title = words(text(one('title')));
+  assert(title === source.title || title.startsWith(`${source.title} | `), `${label}: document title: ${title}`);
+  assert.equal(words(text(one('h1'))), words(source.title), `${label}: H1`);
+  const content = elements(tree, (node) => hasClass(node, 'sl-markdown-content'));
+  assert.equal(content.length, 1, `${label}: expected one Starlight content container`);
+  assert.deepEqual(headings(content[0]), source.headings, `${label}: content headings`);
+  assert.deepEqual(codeBlocks(content[0]), source.code, `${label}: fenced/code block text`);
+  function checkMarkers(node, inCode = false) {
+    const code = inCode || node.tagName === 'code' || node.tagName === 'pre';
+    if (!code && node.type === 'text') {
+      assert(!/\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/.test(node.value),
+        `${label}: unrendered GitHub admonition marker`);
+    }
+    for (const child of node.children ?? []) checkMarkers(child, code);
+  }
+  checkMarkers(content[0]);
+  assert.equal(words(prose(content[0])), source.prose, `${label}: source prose (translation or fallback)`);
+}
+
+export async function verifyOutput({ entries, config, routes, website, output, markdownOptions }) {
+  const api = await parsers(website);
+  const pages = expectedPages(entries, config.i18n);
+  compareRoutes(await htmlFiles(output), pages, extraPages(routes));
+  const sources = new Map();
+  for (const [file, expected] of pages) {
+    const { entry } = expected;
+    if (!sources.has(entry.id)) sources.set(entry.id, await sourceContent(entry, website, api, markdownOptions));
+    comparePage(await readFile(join(output, file), 'utf8'), expected, sources.get(entry.id), api, file);
+  }
+  console.log(`Verified ${pages.size} source-derived pages, including localized content and fallbacks.`);
+}
diff --git a/.github/skills/build-and-verify-docs/scripts/verify.mjs b/.github/skills/build-and-verify-docs/scripts/verify.mjs
new file mode 100644
index 00000000..dba9f75c
--- /dev/null
+++ b/.github/skills/build-and-verify-docs/scripts/verify.mjs
@@ -0,0 +1,126 @@
+#!/usr/bin/env node
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { constants } from 'node:os';
+import { realpathSync } from 'node:fs';
+import { mkdtemp, readFile, writeFile, rm, lstat, mkdir, symlink } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { verifyOutput, htmlFiles, parsers } from './content.mjs';
+
+export const repository = fileURLToPath(new URL('../../../../', import.meta.url));
+
+export function run(command, args, options) {
+  const child = spawnSync(command, args, { stdio: 'inherit', ...options });
+  if (child.error) throw new Error(`Cannot run ${command}: ${child.error.message}`, { cause: child.error });
+  if (child.status !== 0) {
+    const error = new Error(`${command} failed${child.signal ? ` (${child.signal})` : ` with exit ${child.status}`}.`);
+    error.exitCode = child.status ?? 128 + constants.signals[child.signal];
+    throw error;
+  }
+}
+
+export async function temporary(prefix, action, parent = tmpdir()) {
+  const directory = await mkdtemp(join(parent, prefix));
+  try {
+    return await action(directory);
+  } finally {
+    // This directory was created by this invocation, never supplied by configuration or a caller.
+    await rm(directory, { recursive: true });
+  }
+}
+
+export async function validateRoot(root) {
+  const website = join(root, 'website');
+  const stat = await lstat(website);
+  assert(stat.isDirectory() && !stat.isSymbolicLink(), 'Site root must be a real directory, not a symlink.');
+  const pkg = JSON.parse(await readFile(join(website, 'package.json'), 'utf8'));
+  assert(pkg.name === 'copilot-workshops-docs', `Not the workshop site root: ${root}`);
+  for (const script of ['check:all', 'build', 'astro']) assert(pkg.scripts?.[script], `Missing package script: ${script}`);
+  for (const path of ['astro.config.mjs', 'src/content.config.ts']) {
+    assert((await lstat(join(website, path))).isFile(), `Missing site configuration: ${path}`);
+  }
+  return website;
+}
+
+export async function safeOutput(website, config, mustExist = false) {
+  assert.equal(config.output, 'static', 'Only static site output is supported.');
+  assert.equal(config.format, 'directory', 'Only directory-format pages are supported.');
+  assert.equal(fileURLToPath(config.root), `${website}/`, 'Resolved site root does not match the verifier.');
+  const output = fileURLToPath(config.outDir).replace(/\/$/, '');
+  // Do not turn a configuration typo into deletion of source or arbitrary directories.
+  assert.equal(output, join(website, 'dist'), 'Refusing to clean an outDir other than website/dist.');
+  try {
+    const stat = await lstat(output);
+    assert(stat.isDirectory() && !stat.isSymbolicLink(), 'Build output must be a real directory, not a symlink.');
+  } catch (error) {
+    if (error.code !== 'ENOENT' || mustExist) throw error;
+  }
+  return output;
+}
+
+export function baseSegments(base) {
+  assert(typeof base === 'string' && base.startsWith('/'), 'Site base must be an absolute URL path.');
+  const segments = base.split('/').filter(Boolean);
+  assert(!segments.some((part) => part === '.' || part === '..' || /[%\\?#]/.test(part)),
+    `Unsafe or unsupported site base: ${base}`);
+  return segments;
+}
+
+export async function checkLinks(output, base) {
+  const segments = baseSegments(base);
+  const files = await htmlFiles(output);
+  assert(files.length, 'No built HTML found for link validation.');
+  await temporary('workshop-links-', async (directory) => {
+    const root = segments.length ? join(directory, 'root') : output;
+    if (segments.length) {
+      const target = join(root, ...segments);
+      await mkdir(dirname(target), { recursive: true });
+      await symlink(output, target, 'dir');
+    }
+    // Lychee expands this pattern itself; spawning without a shell preserves paths with spaces.
+    run('lychee', ['--offline', '--no-progress', '--root-dir', root, join(output, '**/*.html')]);
+  });
+}
+
+export async function main(args = process.argv.slice(2)) {
+  assert(args.length <= 1 && ['all', 'build', 'links'].includes(args[0] ?? 'all'),
+    'Usage: node verify.mjs [all|build|links]');
+  assert(process.platform !== 'win32', 'This workflow is tested on POSIX systems; native Windows is not supported.');
+  const mode = args[0] ?? 'all';
+  const website = await validateRoot(repository);
+  await parsers(website); // Fail before any cleanup if the declared dependencies are unavailable.
+  await temporary('workshop-verify-', async (directory) => {
+    await writeFile(join(directory, 'owner'), `${website}/`);
+    const env = { ...process.env, DOCS_VERIFY_CAPTURE: directory };
+    const options = { cwd: website, env };
+    run('npm', mode === 'links' ? ['run', 'astro', '--', 'sync'] : ['run', 'check:all'], options);
+    const captured = async (name) => JSON.parse(await readFile(join(directory, name), 'utf8'));
+    const config = await captured('config.json');
+    const output = await safeOutput(website, config, mode === 'links');
+    if (mode === 'links') {
+      await checkLinks(output, config.base);
+      return;
+    }
+    await rm(output, { recursive: true, force: true });
+    run('npm', ['run', 'build'], options);
+    const builtConfig = await captured('config.json');
+    assert.equal(await safeOutput(website, builtConfig, true), output);
+    await verifyOutput({
+      entries: await captured('sources.json'),
+      config: builtConfig,
+      routes: await captured('routes.json'),
+      website, output,
+      markdownOptions: builtConfig.markdown,
+    });
+    if (mode === 'all') await checkLinks(output, builtConfig.base);
+  });
+}
+
+if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  main().catch((error) => {
+    console.error(error.stack ?? error.message);
+    process.exitCode = error.exitCode ?? 1;
+  });
+}
diff --git a/.github/skills/build-and-verify-docs/scripts/verify.test.mjs b/.github/skills/build-and-verify-docs/scripts/verify.test.mjs
new file mode 100644
index 00000000..1b9ff64a
--- /dev/null
+++ b/.github/skills/build-and-verify-docs/scripts/verify.test.mjs
@@ -0,0 +1,198 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { readFile, writeFile, mkdir, cp, symlink, readdir, lstat, rm } from 'node:fs/promises';
+import { join } from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { expectedPages, compareRoutes, comparePage, extraPages, parsers, sourceContent } from './content.mjs';
+import { repository, temporary, safeOutput, validateRoot, baseSegments, run } from './verify.mjs';
+
+const api = await parsers(join(repository, 'website'));
+const i18n = {
+  defaultLocale: 'en',
+  locales: [{ path: 'en', codes: ['en'] }, { path: 'fr', codes: ['fr-FR'] }],
+  routing: { prefixDefaultLocale: false },
+};
+const entry = (id, title = id, draft = false) => ({ id, data: { title, draft } });
+
+test('routes derive from slugs, locale configuration, translations and draft status', () => {
+  const entries = [entry('index'), entry('topic/custom'), entry('fr/topic/custom', 'Traduit'), entry('draft', '', true)];
+  const pages = expectedPages(entries, i18n);
+  assert.equal(pages.get('index.html').lang, 'en');
+  assert.equal(pages.get('fr/index.html').fallback, true);
+  assert.equal(pages.get('fr/topic/custom/index.html').entry.data.title, 'Traduit');
+  assert.equal(pages.get('fr/topic/custom/index.html').fallback, false);
+  assert(!pages.has('draft/index.html'));
+  const expanded = expectedPages([...entries, entry('new lesson')], {
+    ...i18n, locales: [...i18n.locales, { path: 'de', codes: ['de-DE'] }],
+  });
+  assert.equal(expanded.get('de/new lesson/index.html').lang, 'de-DE');
+  assert.equal(expanded.get('de/new lesson/index.html').fallback, true);
+  assert.throws(() => expectedPages([entry('../escape')], i18n), /Unsafe route/);
+  assert.throws(() => expectedPages([entry('index'), entry('')], i18n), /Duplicate/);
+  assert.throws(() => expectedPages(entries, { ...i18n, routing: 'manual' }), /Unsupported locale/);
+});
+
+test('missing, removed and support routes are detected, rather than comparing totals', () => {
+  const pages = expectedPages([entry('index')], i18n);
+  const files = [...pages.keys()];
+  compareRoutes(files, pages, new Set());
+  assert.throws(() => compareRoutes(files.slice(1), pages, new Set()), /Missing: index.html/);
+  for (const extra of ['removed/index.html', '_images/index.html']) {
+    assert.throws(() => compareRoutes([...files, extra], pages, new Set()), /Unexpected:/);
+  }
+  assert.throws(() => compareRoutes([files[0], 'wrong/index.html'], pages, new Set()), /Missing:.*Unexpected:/s);
+});
+
+test('intentional static extras come from resolved routes; unknown dynamic pages fail explicitly', () => {
+  const collection = {
+    pattern: '/[...slug]', params: ['...slug'], type: 'page', isPrerendered: true,
+    entrypoint: '@astrojs/starlight/routes/static/index.astro',
+  };
+  const staticPage = (pathname) => ({ pathname, params: [], type: 'page', isPrerendered: true });
+  assert.deepEqual([...extraPages([collection, staticPage('/404'), staticPage('/legacy/moved')])],
+    ['404.html', 'legacy/moved/index.html']);
+  assert.throws(() => extraPages([{ ...collection, entrypoint: 'src/pages/[...slug].astro' }]), /Unsupported dynamic/);
+});
+
+const source = {
+  title: 'Fish & chips', headings: [[2, 'Read carefully']], code: ['echo ""\n\n  keep spacing'],
+  prose: 'Read carefully Translated body.',
+};
+const html = `Fish & chips | Site
+

Fish & chips

Read carefullyPermalink

+

Translated body.

echo "<ok>"
+
\n
keep spacing
`; + +test('HTML5 parsing decodes entities and preserves exact fenced text, blank lines and indentation', () => { + comparePage(html, { lang: 'fr-FR' }, source, api, 'fixture'); +}); + +for (const [label, before, after, diagnostic] of [ + ['language', 'lang="fr-FR"', 'lang="en"', /language/], + ['title', 'Fish', '<title>Wrong', /document title/], + ['H1', '<h1>Fish', '<h1>Wrong', /H1/], + ['heading', '<h2>Read', '<h2>Wrong', /headings/], + ['fenced prompt', 'keep spacing', 'changed prompt', /code block/], + ['fenced indentation', ' keep spacing', 'keep spacing', /code block/], + ['translation content', 'Translated body.', 'English fallback.', /source prose/], + ['raw admonition', 'Translated body.', '[!TIP] Translated body.', /source prose|admonition/], +]) { + test(`rejects ${label} mismatch`, () => { + assert.throws(() => comparePage(html.replace(before, after), { lang: 'fr-FR' }, source, api, 'fixture'), diagnostic); + }); +} + +test('source frontmatter uses the public YAML parser; exact code and entity-rich headings are derived', async () => { + await temporary('workshop-source-', async (directory) => { + const website = join(directory, 'website'); + await mkdir(website); + const markdown = '---\ntitle: >-\n Fish & chips\n---\n## Read *carefully*\n\nTranslated body.\n\n```text\necho "<ok>"\n\n keep spacing\n```\n'; + await writeFile(join(directory, 'a file.md'), markdown); + const parsed = api.parseFrontmatter(markdown); + const data = { filePath: '../a file.md', body: parsed.content, data: parsed.frontmatter }; + assert.deepEqual(await sourceContent(data, website, api, {}), source); + await assert.rejects(sourceContent({ ...data, body: 'stale' }, website, api, {}), /changed since capture/); + await assert.rejects(sourceContent({ ...data, filePath: '../a.mdx' }, website, api, {}), /Unsupported source type/); + }); +}); + +test('wrong roots, unsafe output paths, symlinks, missing artifacts and unsafe base paths fail', async () => { + await temporary('workshop-safety-', async (directory) => { + await assert.rejects(validateRoot(directory), /ENOENT/); + const website = join(directory, 'website'); + await mkdir(website); + const config = { + root: pathToFileURL(`${website}/`).href, outDir: pathToFileURL(`${website}/dist/`).href, + output: 'static', format: 'directory', + }; + assert.equal(await safeOutput(website, config), join(website, 'dist')); + await assert.rejects(safeOutput(website, config, true), /ENOENT/); + await assert.rejects(safeOutput(website, { ...config, outDir: config.root }), /Refusing to clean/); + await symlink(directory, join(website, 'dist')); + await assert.rejects(safeOutput(website, config), /symlink/); + for (const base of ['../bad', '/../../', '/a\\b/', '/%2e%2e/']) assert.throws(() => baseSegments(base)); + assert.deepEqual(baseSegments('/nested/site/'), ['nested', 'site']); + assert.deepEqual(baseSegments('/'), []); + }); +}); + +test('native subprocess exit codes are preserved; missing tools fail explicitly', () => { + assert.throws(() => run(process.execPath, ['-e', 'process.exit(7)']), (error) => error.exitCode === 7); + assert.throws(() => run('/nonexistent/workshop-verification-tool', []), /Cannot run/); +}); + +test('script-owned temporary directories are cleaned on success and failure without following symlinks', async () => { + let removed; + await temporary('workshop-outer-', async (outside) => { + const sentinel = join(outside, 'keep'); + await writeFile(sentinel, 'unchanged'); + await assert.rejects(temporary('workshop-inner-', async (directory) => { + removed = directory; + await symlink(outside, join(directory, 'link')); + throw new Error('deliberate failure'); + }), /deliberate/); + assert.equal(await readFile(sentinel, 'utf8'), 'unchanged'); + }); + await assert.rejects(lstat(removed), /ENOENT/); +}); + +test('real entry point builds dynamic lesson/locale fixtures from another cwd with spaces', { timeout: 600000 }, async () => { + await mkdir(join(repository, 'website/.astro'), { recursive: true }); + await temporary('workshop fixture ', async (fixture) => { + const website = join(fixture, 'website'); + const scripts = join(fixture, '.github/skills/build-and-verify-docs/scripts'); + await mkdir(join(website, 'src'), { recursive: true }); + await mkdir(join(fixture, 'docs/_support'), { recursive: true }); + await mkdir(join(fixture, 'temp area')); + await cp(join(repository, '.github/skills/build-and-verify-docs/scripts'), scripts, { recursive: true }); + for (const file of ['package.json', 'tsconfig.json', 'tsconfig.tsgo.json', 'src/content.config.ts']) { + await cp(join(repository, 'website', file), join(website, file)); + } + await cp(join(repository, 'website/src/pages'), join(website, 'src/pages'), { recursive: true }); + await cp(join(repository, 'website/public'), join(website, 'public'), { recursive: true }); + await symlink(join(repository, 'website/node_modules'), join(website, 'node_modules'), 'dir'); + const config = (locales) => ` +import { defineConfig } from 'astro/config'; +import starlight from '@astrojs/starlight'; +import { unified } from '@astrojs/markdown-remark'; +import { verificationIntegration } from '../.github/skills/build-and-verify-docs/scripts/capture.mjs'; +export default defineConfig({ site: 'https://example.test', base: '/nested/workshop', trailingSlash: 'always', + markdown: { processor: unified() }, + integrations: [verificationIntegration(), starlight({title: 'Fixture', locales: ${JSON.stringify(locales)}})] }); +`; + const locales = { root: { label: 'English', lang: 'en' }, fr: { label: 'French', lang: 'fr-FR' } }; + await writeFile(join(website, 'astro.config.mjs'), config(locales)); + await writeFile(join(fixture, 'docs/README.md'), '---\ntitle: Home\nslug: index\n---\n## Start\n\nWelcome.\n'); + await writeFile(join(fixture, 'docs/a file.md'), '---\ntitle: Lesson\nslug: custom-route\n---\n## Task\n\nDo this.\n\n```text\nprompt & <literal>\n\n indentation\n```\n'); + await writeFile(join(fixture, 'docs/_support/hidden.md'), '---\ntitle: Must not be routed\n---\n'); + const invoke = (mode, expectedStatus = 0) => { + const started = performance.now(); + const result = spawnSync(process.execPath, [join(scripts, 'verify.mjs'), mode], { + cwd: join(fixture, 'temp area'), encoding: 'utf8', maxBuffer: 5_000_000, + env: { ...process.env, TMPDIR: join(fixture, 'temp area') }, + }); + assert.equal(result.status, expectedStatus, `${result.stdout}\n${result.stderr}`); + console.log(`Fixture ${mode}: exit ${result.status}, ${Math.round(performance.now() - started)} ms`); + return result.stdout; + }; + assert.match(invoke('build'), /Verified .* source-derived pages/); + assert((await lstat(join(website, 'dist/fr/custom-route/index.html'))).isFile()); + await mkdir(join(fixture, 'docs/fr')); + await writeFile(join(fixture, 'docs/fr/a file.md'), '---\ntitle: Leçon\nslug: fr/custom-route\n---\n## Travail\n\nTexte traduit.\n'); + await writeFile(join(fixture, 'docs/new lesson.md'), '---\ntitle: New lesson\n---\n## New task\n\nNew work.\n'); + await writeFile(join(website, 'astro.config.mjs'), config({ ...locales, de: { label: 'German', lang: 'de-DE' } })); + assert.match(invoke('all'), /Verified .* source-derived pages/); + const translated = await readFile(join(website, 'dist/fr/custom-route/index.html'), 'utf8'); + assert.match(translated, /Texte traduit/); + assert((await lstat(join(website, 'dist/de/new-lesson/index.html'))).isFile()); + await writeFile(join(website, 'dist/fr/custom-route/index.html'), + translated.replace('</body>', '<a href="/nested/workshop/missing/">Broken</a></body>')); + invoke('links', 2); + assert.deepEqual((await readdir(join(fixture, 'temp area'))).filter((name) => name.startsWith('workshop-')), []); + // Removing the source must also remove its old output on the next clean build. + await rm(join(fixture, 'docs/new lesson.md')); + invoke('build'); + await assert.rejects(lstat(join(website, 'dist/de/new-lesson/index.html')), /ENOENT/); + }, join(repository, 'website/.astro')); +}); diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 2abc22db..8c6d4679 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; import remarkGithubAdmonitionsToDirectives from 'remark-github-admonitions-to-directives'; +import { verificationIntegration } from '../.github/skills/build-and-verify-docs/scripts/capture.mjs'; // Lesson callouts are authored in GitHub admonition syntax (`> [!NOTE]`). This // remark plugin rewrites them into Starlight aside directives before Starlight @@ -27,6 +28,7 @@ export default defineConfig({ ], }, integrations: [ + verificationIntegration(), starlight({ title: 'Copilot Workshops', description: diff --git a/website/package-lock.json b/website/package-lock.json index 54dd315f..f30b85ae 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@typescript/native-preview": "^7.0.0-dev.20260707.2", + "hast-util-from-html": "2.0.3", "remark-github-admonitions-to-directives": "^2.1.0" } }, diff --git a/website/package.json b/website/package.json index 0a82e4ae..95faed74 100644 --- a/website/package.json +++ b/website/package.json @@ -23,6 +23,7 @@ }, "devDependencies": { "@typescript/native-preview": "^7.0.0-dev.20260707.2", + "hast-util-from-html": "2.0.3", "remark-github-admonitions-to-directives": "^2.1.0" }, "allowScripts": { diff --git a/website/src/content.config.ts b/website/src/content.config.ts index ba0a5873..94e6e24b 100644 --- a/website/src/content.config.ts +++ b/website/src/content.config.ts @@ -2,6 +2,7 @@ import { defineCollection } from 'astro:content'; import { glob } from 'astro/loaders'; import { docsSchema } from '@astrojs/starlight/schema'; import { z } from 'astro:schema'; +import { captureLoader } from '../../.github/skills/build-and-verify-docs/scripts/capture.mjs'; // We use Astro's `glob()` loader directly (instead of Starlight's `docsLoader()`) // so we can exclude underscore-prefixed *directories* like `_images/`. Starlight's @@ -16,10 +17,10 @@ import { z } from 'astro:schema'; // fields pre-stage the metadata the awesome-copilot Learning Hub mirror expects. export const collections = { docs: defineCollection({ - loader: glob({ + loader: captureLoader(glob({ base: '../docs', pattern: ['**/*.{md,mdx}', '!**/_*/**', '!**/_*'], - }), + })), schema: docsSchema({ extend: z.object({ authors: z.array(z.string()).optional(), From 7407858a6bf8c994073b4876d7e4d99c3dd540e5 Mon Sep 17 00:00:00 2001 From: Christopher Harrison <geektrainer@github.com> Date: Mon, 14 Sep 2026 17:44:22 -0700 Subject: [PATCH 3/3] chore: simplify verification to existing shell checks Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/skills/README.md | 2 +- .github/skills/build-and-verify-docs/SKILL.md | 69 ++---- .../build-and-verify-docs/scripts/capture.mjs | 85 ------- .../build-and-verify-docs/scripts/content.mjs | 224 ------------------ .../build-and-verify-docs/scripts/verify.mjs | 126 ---------- .../build-and-verify-docs/scripts/verify.ps1 | 53 +++++ .../build-and-verify-docs/scripts/verify.sh | 32 +++ .../scripts/verify.test.mjs | 198 ---------------- website/astro.config.mjs | 2 - website/package-lock.json | 1 - website/package.json | 1 - website/src/content.config.ts | 5 +- 12 files changed, 112 insertions(+), 686 deletions(-) delete mode 100644 .github/skills/build-and-verify-docs/scripts/capture.mjs delete mode 100644 .github/skills/build-and-verify-docs/scripts/content.mjs delete mode 100644 .github/skills/build-and-verify-docs/scripts/verify.mjs create mode 100644 .github/skills/build-and-verify-docs/scripts/verify.ps1 create mode 100644 .github/skills/build-and-verify-docs/scripts/verify.sh delete mode 100644 .github/skills/build-and-verify-docs/scripts/verify.test.mjs diff --git a/.github/skills/README.md b/.github/skills/README.md index 84f063e5..eb10bd7f 100644 --- a/.github/skills/README.md +++ b/.github/skills/README.md @@ -10,7 +10,7 @@ These encode conventions unique to this Astro + Starlight workshop content repo. | Skill | What it does | Use it when | |---|---|---| -| [`build-and-verify-docs`](./build-and-verify-docs/SKILL.md) | Bundled scripts for type checks, a clean build, source-derived output verification, and offline links; AI guidance for semantic, visual, and structural review. | Building, previewing, or verifying the site; before every commit/PR. | +| [`build-and-verify-docs`](./build-and-verify-docs/SKILL.md) | Small Bash and PowerShell wrappers for existing type checks, build, and offline links; AI guidance for source-page, semantic, visual, and structural review. | Building, previewing, or verifying the site; before every commit/PR. | | [`check-content-alignment`](./check-content-alignment/SKILL.md) | Uses the diff and current content tree to find inconsistent repeated passages, parallel concepts, translations, and cross-references. Reports candidates without editing content. | After lesson edits or when reviewing content drift. | | [`validate-site-playwright`](./validate-site-playwright/SKILL.md) | Optional deeper **browser** QA: drives the Playwright MCP server against a local preview to confirm pages render, catch console/hydration errors, find broken images, and confirm Starlight components mounted. | A deeper render/visual pass before a PR that changes how pages render. Complements (doesn't replace) `build-and-verify-docs`. | diff --git a/.github/skills/build-and-verify-docs/SKILL.md b/.github/skills/build-and-verify-docs/SKILL.md index e1992fd9..22d81b16 100644 --- a/.github/skills/build-and-verify-docs/SKILL.md +++ b/.github/skills/build-and-verify-docs/SKILL.md @@ -1,77 +1,56 @@ --- name: build-and-verify-docs -description: Build, preview, and verify the workshop site. Use before committing or updating a PR, when checking rendered content or links, or when reviewing structural drift. Derive affected routes and locales from the current source and configuration rather than fixed page counts. +description: Build, preview, and verify the workshop site using existing checks. Use before committing or updating a PR. Inspect affected content and translations separately rather than relying on fixed page counts. --- # Build and verify the docs site -Lesson source lives in `docs/`; the Astro + Starlight publisher lives in `website/`. This skill owns the build and verification procedure. Other authoring guidance should link here instead of repeating commands or expected output totals. +Lesson source lives in `docs/`; the Astro + Starlight publisher lives in `website/`. This skill owns the verification procedure. Other authoring guidance should link here instead of repeating commands. -Use the bundled Node.js entry point for deterministic checks. It derives the repository from its own location, so it also works when invoked by absolute path from another directory. The examples below use Bash from the repository root. Use existing dependencies and tools; ask before installing anything missing. +## Run existing checks -## Local preview +Use the wrapper for your shell, from the repository root or by absolute script path from any directory: ```bash -(cd website && npm run dev) +bash .github/skills/build-and-verify-docs/scripts/verify.sh ``` -Open the local URL printed by the server, using the base path configured in `website/astro.config.mjs`. Stop the server you started when finished. Do not reuse or stop an unrelated server. - -## Verification before committing - -### Run the deterministic checks - -```bash -node .github/skills/build-and-verify-docs/scripts/verify.mjs +```powershell +& ./.github/skills/build-and-verify-docs/scripts/verify.ps1 ``` -The default `all` mode runs the existing `website/package.json` scripts (`check:all`, then `build`), safely cleans `website/dist`, verifies every source-derived page, and runs offline Lychee. It preserves subprocess output and exit codes. Missing tools, missing artifacts, unsafe configuration, unsupported content, or mismatches fail explicitly; stop and resolve failures before committing. - -The scripts use the actual content loader and Astro integration hooks to capture parsed entries, generated IDs/slugs, resolved locales/base path, and intentional static routes. They do not parse configuration with regular expressions or maintain a parallel file inventory. The capture hooks are inactive during ordinary builds. Verification caches stay under the worktree's `website/.astro/`, not shared dependencies. - -The deterministic checks cover: +Use an installed Node.js/npm version supported by `website/`, existing site dependencies, and Lychee on `PATH`. Ask before installing missing software. Run PowerShell under your normal execution policy; do not bypass it. The Bash wrapper has been run on macOS; the PowerShell wrapper has not been executed or parser-validated because PowerShell was unavailable. -- Expected versus actual HTML routes, including missing pages and unexpected stale/support pages. Static extras such as redirects and error pages come from Astro's resolved routes. -- Localized versus default-locale fallback sources, document language, title/H1, ordered headings, normalized visible prose and image alternative text, and exact code-block contents including indentation and blank lines. -- Unconverted GitHub admonition markers outside code examples. -- Offline internal links and images using a unique temporary root mapped to the configured base path. Temporary directories are removed on success or failure. +Both wrappers run `npm run check:all`, remove only `website/dist` (refusing a linked output directory), run `npm run build`, then run `lychee --offline --no-progress` against the built HTML. Package scripts remain the source of truth. Output is not suppressed, failures stop verification, and native command exit codes are returned. Each run creates and cleans its own temporary link root. Like the existing Pages workflow, the wrappers map `/copilot-workshops/` to the build; update that mapping alongside the workflow if the configured site base changes. -Markdown/frontmatter parsing uses the declared `@astrojs/markdown-remark` public API; full-document HTML parsing uses the directly declared `hast-util-from-html` dependency. Code extraction supports plain code blocks and the site's Expressive Code output; unsupported line structures fail rather than guessing. +Stop on failure and resolve it before committing. The wrappers do not install dependencies, start servers, compare source text with HTML, or validate translation quality. Offline Lychee checks internal links and images, not external destinations. -### Select a narrower operation +## Inspect affected content -Use `build` for type checks, a clean build, and source-to-output verification without Lychee. Use `links` to recheck an existing build; it runs the package's Astro sync command to resolve current configuration, but does not rebuild or certify source/output freshness. +Use the diff, source paths and `slug` frontmatter, `website/src/content.config.ts`, and `website/astro.config.mjs` to select and inspect affected output. Do not use a fixed page total as proof of correctness. -```bash -node .github/skills/build-and-verify-docs/scripts/verify.mjs build -node .github/skills/build-and-verify-docs/scripts/verify.mjs links -``` +- Confirm changed lessons appear at their intended routes with their titles, headings, prompts, and admonitions rendered correctly. +- Compare translated pages with their locale source and language; English fallback is expected only when a translation is absent. +- Check references and navigation after renames or removals, and confirm support assets are not published as lessons. +- Review semantic accuracy, teaching flow, translation quality, intentional harness differences, and whether navigation or redirects lead to the appropriate next task. -These scripts target the current static Starlight site with directory-format HTML in `website/dist`. MDX, alternate Markdown processors, unknown prerendered dynamic routes, and manual/domain locale routing fail explicitly until supported with tests. They are tested on macOS/POSIX; native Windows is not supported. They do not install tools, start servers, manage ports, or change running processes. +These inspections are not automated by the wrappers. Use the [browser validation skill][browser-validation] for layout, styling, client-side behavior, console errors, and visual image loading. Existing plain-div admonition styling is not fixed or certified by a successful build. Open changed external links separately and confirm their intended destinations. Report any blocked checks honestly. -### Maintain the scripts - -Run the bundled tests after changing verification logic: +## Local preview ```bash -node --test .github/skills/build-and-verify-docs/scripts/verify.test.mjs +(cd website && npm run dev) ``` -Tests include deliberate mismatches and real fixture builds with added lessons, slugs, translations, locales, changed base paths, filenames and directories containing spaces, and another working directory. The fixtures reuse installed dependencies without installing packages and remove their own temporary files. - -## AI review after the scripts pass - -Use judgment for semantic accuracy, teaching flow, translation quality, intentional harness differences, and whether navigation or a redirect leads to the right next task. Scripts verify what is present, not whether the lesson is pedagogically correct. - -Use the [browser validation skill][browser-validation] when layout, styling, client-side behavior, console errors, or visual image loading requires review. The existing plain-div admonition rendering is not fixed or certified by the raw-marker check; styling still needs browser judgment. Offline checks do not verify external destinations; open changed external links separately and confirm they are the intended resource. Report blocked checks honestly. +Open the URL printed by the server, including the configured base path. Stop only the server you started when finished. ## Consistency before updating a PR -- Search for references to paths, names, or conventions changed by the diff, including repository guidance and navigation. Update affected references without rewriting unrelated documentation. -- For duplicated lesson content, use the [content alignment skill][content-alignment] to identify parallel passages and translations that may need matching changes. -- Keep descriptions of build commands and CI behavior consistent with `website/package.json` and `.github/workflows/pages.yml`. Those files, not copied prose, define what runs. +- Search for references to paths, names, or conventions changed by the diff, including repository guidance and navigation. +- Use the [content alignment skill][content-alignment] for duplicated lesson passages and translations that may need matching changes. +- Keep CI descriptions aligned with `website/package.json` and `.github/workflows/pages.yml`. -The Pages workflow runs type checks, the build, and offline link validation; deployment is restricted to pushes to `main`. The bundled source-to-output verification, browser validation, and content-alignment review are separate from that build job. Consult the workflow for its current triggers and steps. +The Pages workflow remains unchanged: it runs type checks, the build, and offline link validation, with deployment restricted to pushes to `main`. Source-page inspection, browser validation, and content-alignment review remain separate. [browser-validation]: ../validate-site-playwright/SKILL.md [content-alignment]: ../check-content-alignment/SKILL.md diff --git a/.github/skills/build-and-verify-docs/scripts/capture.mjs b/.github/skills/build-and-verify-docs/scripts/capture.mjs deleted file mode 100644 index 28551aa2..00000000 --- a/.github/skills/build-and-verify-docs/scripts/capture.mjs +++ /dev/null @@ -1,85 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -// Only the verifier sets this variable, pointing at its private temporary directory. -async function record(name, value, root) { - const directory = process.env.DOCS_VERIFY_CAPTURE; - if (!directory) return; - const owner = await readFile(join(directory, 'owner'), 'utf8'); - if (owner !== fileURLToPath(root)) throw new Error('Verification capture belongs to a different site root.'); - await writeFile(join(directory, name), JSON.stringify(value)); -} - -/** @returns {import('astro').AstroIntegration} */ -export function verificationIntegration() { - return { - name: 'workshop-verification', - hooks: process.env.DOCS_VERIFY_CAPTURE ? { - 'astro:config:setup': ({ config, updateConfig }) => { - // Never write caches into node_modules, which may be shared read-only. - updateConfig({ - cacheDir: new URL('./.astro/verification-cache/', config.root), - vite: { cacheDir: fileURLToPath(new URL('./.astro/verification-vite/', config.root)) }, - }); - }, - 'astro:config:done': async ({ config }) => { - if (config.markdown.processor.name !== 'unified') { - throw new Error(`Unsupported Markdown processor: ${config.markdown.processor.name}`); - } - await record('config.json', { - root: config.root.href, - outDir: config.outDir.href, - base: config.base, - format: config.build.format, - output: config.output, - i18n: config.i18n, - markdown: { - gfm: config.markdown.processor.options?.gfm ?? config.markdown.gfm, - smartypants: config.markdown.processor.options?.smartypants ?? config.markdown.smartypants, - }, - }, config.root); - }, - 'astro:routes:resolved': async ({ routes }) => { - const directory = process.env.DOCS_VERIFY_CAPTURE; - const root = new URL(JSON.parse(await readFile(join(directory, 'config.json'), 'utf8')).root); - await record('routes.json', routes.map((route) => ({ - pathname: route.pathname, - pattern: route.pattern, - entrypoint: route.entrypoint, - params: route.params, - type: route.type, - isPrerendered: route.isPrerendered, - })), root); - }, - } : {}, - }; -} - -/** - * Capture the real loader's parsed entries, not a second implementation of its globs or slugging. - * @param {import('astro/loaders').Loader} loader - * @returns {import('astro/loaders').Loader} - */ -export function captureLoader(loader) { - if (!process.env.DOCS_VERIFY_CAPTURE) return loader; - return { - ...loader, - async load(context) { - const errors = []; - const logger = Object.create(context.logger); - logger.error = (message) => { - errors.push(message); - context.logger.error(message); - }; - await loader.load({ - ...context, - logger, - }); - if (errors.length) throw new Error(`Content loader failed: ${errors.join('; ')}`); - await record('sources.json', [...context.store.values()].map(({ id, data, body, filePath }) => ({ - id, data, body, filePath, - })), context.config.root); - }, - }; -} diff --git a/.github/skills/build-and-verify-docs/scripts/content.mjs b/.github/skills/build-and-verify-docs/scripts/content.mjs deleted file mode 100644 index cc832275..00000000 --- a/.github/skills/build-and-verify-docs/scripts/content.mjs +++ /dev/null @@ -1,224 +0,0 @@ -import assert from 'node:assert/strict'; -import { createRequire } from 'node:module'; -import { readFile, readdir } from 'node:fs/promises'; -import { join, resolve, relative, sep } from 'node:path'; -import { pathToFileURL } from 'node:url'; - -export async function parsers(website) { - const require = createRequire(pathToFileURL(join(website, 'package.json'))); - const markdown = await import(require.resolve('@astrojs/markdown-remark')); - const { fromHtml } = await import(require.resolve('hast-util-from-html')); - return { ...markdown, fromHtml }; -} - -export function elements(node, predicate) { - const result = []; - function visit(current) { - if (current.type === 'element' && predicate(current)) result.push(current); - for (const child of current.children ?? []) visit(child); - } - visit(node); - return result; -} - -const hasClass = (node, name) => node.properties?.className?.includes(name); -const words = (value) => value.replace(/\s+/gu, ' ').trim(); -const lines = (value) => value.replace(/\r\n?/g, '\n'); - -function text(node) { - if (node.type === 'text') return node.value; - if (node.tagName === 'br') return '\n'; - if (node.tagName === 'img') return node.properties?.alt ?? ''; - // Starlight adds accessible permalink labels which are not part of source headings. - if (node.tagName === 'a' && hasClass(node, 'sl-anchor-link')) return ''; - return (node.children ?? []).map(text).join(''); -} - -function headings(tree) { - return elements(tree, (node) => /^h[1-6]$/.test(node.tagName)) - .map((node) => [Number(node.tagName[1]), words(text(node))]); -} - -function prose(node, stripMarkers = false, inCode = false) { - if (['pre', 'style', 'script', 'svg'].includes(node.tagName) || hasClass(node, 'expressive-code') || - (node.tagName === 'a' && hasClass(node, 'sl-anchor-link'))) return ''; - if (node.type === 'text') return stripMarkers && !inCode - ? node.value.replace(/\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/g, '') : node.value; - if (node.tagName === 'img') return node.properties?.alt ?? ''; - const value = (node.children ?? []).map((child) => prose(child, stripMarkers, inCode || node.tagName === 'code')).join(''); - return ['p', 'div', 'li', 'td', 'th', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName) - ? ` ${value} ` : value; -} - -function codeBlocks(tree) { - return elements(tree, (node) => node.tagName === 'pre').map((pre) => { - const codes = elements(pre, (node) => node.tagName === 'code'); - assert.equal(codes.length, 1, 'Unsupported code block: expected one <code> per <pre>.'); - const code = codes[0]; - const expressiveLines = elements(code, (node) => hasClass(node, 'ec-line')); - if (expressiveLines.length) { - return expressiveLines.map((line) => { - const content = elements(line, (node) => hasClass(node, 'code')); - assert.equal(content.length, 1, 'Unsupported Expressive Code line structure.'); - const value = text(content[0]); - // Expressive Code uses a single newline as the placeholder for an empty visual line. - if (value === '\n') return ''; - assert(!value.includes('\n'), 'Unsupported multiline Expressive Code line.'); - return value; - }).join('\n'); - } - assert.equal(elements(code, (node) => ['div', 'table'].includes(node.tagName)).length, 0, - 'Unsupported code renderer: cannot infer line breaks.'); - return lines(text(code)).replace(/\n$/, ''); - }); -} - -export function routeFile(id) { - const route = id === 'index' ? '' : id.replace(/\/index$/, '').normalize(); - assert(!route.startsWith('/') && !route.includes('\\') && !/[?#%]/.test(route), - `Unsupported route slug: ${id}`); - assert(!route.split('/').some((part) => part === '.' || part === '..'), `Unsafe route slug: ${id}`); - return route ? `${route}/index.html` : 'index.html'; -} - -export function expectedPages(entries, i18n) { - assert(i18n && Array.isArray(i18n.locales) && i18n.locales.length, 'Missing resolved locale configuration.'); - assert(i18n.routing !== 'manual' && !i18n.domains, 'Unsupported locale routing: manual routing or domains.'); - const locales = i18n.locales.map((locale) => typeof locale === 'string' - ? { path: locale, lang: locale } - : { path: locale.path, lang: locale.codes[0] }); - const defaultLocale = locales.find((locale) => locale.path === i18n.defaultLocale); - assert(defaultLocale, 'Default locale is not configured.'); - if (!i18n.routing.prefixDefaultLocale) defaultLocale.path = ''; - for (const locale of locales) { - assert(typeof locale.lang === 'string' && locale.lang.length, 'Locale requires a language tag.'); - routeFile(locale.path); - } - const localized = (id) => locales.find((locale) => - locale.path && (id === locale.path || id.startsWith(`${locale.path}/`))) ?? - locales.find((locale) => locale.path === ''); - const pages = new Map(); - const add = (id, entry, locale, fallback) => { - const file = routeFile(id); - assert(!pages.has(file), `Duplicate output route: ${file}`); - pages.set(file, { entry, lang: locale.lang, fallback }); - }; - const published = entries.filter((entry) => !entry.data.draft); - assert(published.length, 'No published source entries were captured.'); - for (const entry of published) { - assert(typeof entry.id === 'string' && typeof entry.data.title === 'string', 'Invalid captured source entry.'); - const id = entry.id === 'index' ? '' : entry.id; - const locale = localized(id); - assert(locale, `Source does not belong to a configured locale: ${entry.id}`); - add(id, entry, locale, false); - } - for (const entry of published) { - const id = entry.id === 'index' ? '' : entry.id; - if (localized(id) !== defaultLocale) continue; - const suffix = defaultLocale.path ? id.slice(defaultLocale.path.length).replace(/^\//, '') : id; - for (const locale of locales) { - const target = [locale.path, suffix].filter(Boolean).join('/'); - if (!pages.has(routeFile(target))) add(target, entry, locale, true); - } - } - return pages; -} - -export function extraPages(routes) { - const extras = new Set(); - let collectionRoute = false; - for (const route of routes) { - if (route.type === 'endpoint' || !route.isPrerendered) continue; - if (route.params.length) { - assert(route.pattern === '/[...slug]' && - route.entrypoint.includes('@astrojs/starlight/routes/static/index.astro'), - `Unsupported dynamic page route: ${route.pattern} (${route.entrypoint})`); - collectionRoute = true; - continue; - } - assert(typeof route.pathname === 'string', `Missing static pathname: ${route.pattern}`); - const pathname = route.pathname.replace(/^\/|\/$/g, ''); - // Astro emits its special 404/500 routes as files, regardless of build.format. - extras.add(['404', '500'].includes(pathname) ? `${pathname}.html` : routeFile(pathname)); - } - assert(collectionRoute, 'The resolved Starlight collection route was not found.'); - return extras; -} - -export async function htmlFiles(directory) { - const files = []; - for (const entry of await readdir(directory, { withFileTypes: true })) { - assert(!entry.isSymbolicLink(), `Unexpected symlink in build output: ${join(directory, entry.name)}`); - if (entry.isDirectory()) { - for (const child of await htmlFiles(join(directory, entry.name))) files.push(`${entry.name}/${child}`); - } else if (entry.name.endsWith('.html')) files.push(entry.name); - } - return files; -} - -export function compareRoutes(actual, pages, extras) { - const expected = new Set([...pages.keys(), ...extras]); - const found = new Set(actual); - const missing = [...expected].filter((file) => !found.has(file)); - const unexpected = [...found].filter((file) => !expected.has(file)); - assert(!missing.length && !unexpected.length, - `Route mismatch.\nMissing: ${missing.join(', ') || '(none)'}\nUnexpected: ${unexpected.join(', ') || '(none)'}`); -} - -export async function sourceContent(entry, website, api, markdownOptions) { - assert(entry.filePath?.endsWith('.md'), `Unsupported source type: ${entry.filePath}; Markdown is required.`); - const file = resolve(website, entry.filePath); - const repo = resolve(website, '..'); - assert(!relative(repo, file).startsWith(`..${sep}`), `Source escapes repository: ${file}`); - const parsed = api.parseFrontmatter(await readFile(file, 'utf8')); - assert.equal(parsed.frontmatter.title, entry.data.title, `Source title changed since capture: ${file}`); - assert.equal(lines(parsed.content).trim(), lines(entry.body).trim(), `Source body changed since capture: ${file}`); - const processor = await api.createMarkdownProcessor({ ...markdownOptions, syntaxHighlight: false }); - // Omit fileURL so the public renderer retains <img alt>, rather than build-time asset placeholders. - const rendered = await processor.render(parsed.content, { frontmatter: parsed.frontmatter }); - const tree = api.fromHtml(rendered.code, { fragment: true }); - return { - title: entry.data.title, headings: headings(tree), code: codeBlocks(tree), - prose: words(prose(tree, true)), - }; -} - -export function comparePage(html, expected, source, api, label) { - const tree = api.fromHtml(html); - const one = (tag) => { - const nodes = elements(tree, (node) => node.tagName === tag); - assert.equal(nodes.length, 1, `${label}: expected exactly one ${tag}`); - return nodes[0]; - }; - assert.equal(one('html').properties.lang, expected.lang, `${label}: document language`); - const title = words(text(one('title'))); - assert(title === source.title || title.startsWith(`${source.title} | `), `${label}: document title: ${title}`); - assert.equal(words(text(one('h1'))), words(source.title), `${label}: H1`); - const content = elements(tree, (node) => hasClass(node, 'sl-markdown-content')); - assert.equal(content.length, 1, `${label}: expected one Starlight content container`); - assert.deepEqual(headings(content[0]), source.headings, `${label}: content headings`); - assert.deepEqual(codeBlocks(content[0]), source.code, `${label}: fenced/code block text`); - function checkMarkers(node, inCode = false) { - const code = inCode || node.tagName === 'code' || node.tagName === 'pre'; - if (!code && node.type === 'text') { - assert(!/\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/.test(node.value), - `${label}: unrendered GitHub admonition marker`); - } - for (const child of node.children ?? []) checkMarkers(child, code); - } - checkMarkers(content[0]); - assert.equal(words(prose(content[0])), source.prose, `${label}: source prose (translation or fallback)`); -} - -export async function verifyOutput({ entries, config, routes, website, output, markdownOptions }) { - const api = await parsers(website); - const pages = expectedPages(entries, config.i18n); - compareRoutes(await htmlFiles(output), pages, extraPages(routes)); - const sources = new Map(); - for (const [file, expected] of pages) { - const { entry } = expected; - if (!sources.has(entry.id)) sources.set(entry.id, await sourceContent(entry, website, api, markdownOptions)); - comparePage(await readFile(join(output, file), 'utf8'), expected, sources.get(entry.id), api, file); - } - console.log(`Verified ${pages.size} source-derived pages, including localized content and fallbacks.`); -} diff --git a/.github/skills/build-and-verify-docs/scripts/verify.mjs b/.github/skills/build-and-verify-docs/scripts/verify.mjs deleted file mode 100644 index dba9f75c..00000000 --- a/.github/skills/build-and-verify-docs/scripts/verify.mjs +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { constants } from 'node:os'; -import { realpathSync } from 'node:fs'; -import { mkdtemp, readFile, writeFile, rm, lstat, mkdir, symlink } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { verifyOutput, htmlFiles, parsers } from './content.mjs'; - -export const repository = fileURLToPath(new URL('../../../../', import.meta.url)); - -export function run(command, args, options) { - const child = spawnSync(command, args, { stdio: 'inherit', ...options }); - if (child.error) throw new Error(`Cannot run ${command}: ${child.error.message}`, { cause: child.error }); - if (child.status !== 0) { - const error = new Error(`${command} failed${child.signal ? ` (${child.signal})` : ` with exit ${child.status}`}.`); - error.exitCode = child.status ?? 128 + constants.signals[child.signal]; - throw error; - } -} - -export async function temporary(prefix, action, parent = tmpdir()) { - const directory = await mkdtemp(join(parent, prefix)); - try { - return await action(directory); - } finally { - // This directory was created by this invocation, never supplied by configuration or a caller. - await rm(directory, { recursive: true }); - } -} - -export async function validateRoot(root) { - const website = join(root, 'website'); - const stat = await lstat(website); - assert(stat.isDirectory() && !stat.isSymbolicLink(), 'Site root must be a real directory, not a symlink.'); - const pkg = JSON.parse(await readFile(join(website, 'package.json'), 'utf8')); - assert(pkg.name === 'copilot-workshops-docs', `Not the workshop site root: ${root}`); - for (const script of ['check:all', 'build', 'astro']) assert(pkg.scripts?.[script], `Missing package script: ${script}`); - for (const path of ['astro.config.mjs', 'src/content.config.ts']) { - assert((await lstat(join(website, path))).isFile(), `Missing site configuration: ${path}`); - } - return website; -} - -export async function safeOutput(website, config, mustExist = false) { - assert.equal(config.output, 'static', 'Only static site output is supported.'); - assert.equal(config.format, 'directory', 'Only directory-format pages are supported.'); - assert.equal(fileURLToPath(config.root), `${website}/`, 'Resolved site root does not match the verifier.'); - const output = fileURLToPath(config.outDir).replace(/\/$/, ''); - // Do not turn a configuration typo into deletion of source or arbitrary directories. - assert.equal(output, join(website, 'dist'), 'Refusing to clean an outDir other than website/dist.'); - try { - const stat = await lstat(output); - assert(stat.isDirectory() && !stat.isSymbolicLink(), 'Build output must be a real directory, not a symlink.'); - } catch (error) { - if (error.code !== 'ENOENT' || mustExist) throw error; - } - return output; -} - -export function baseSegments(base) { - assert(typeof base === 'string' && base.startsWith('/'), 'Site base must be an absolute URL path.'); - const segments = base.split('/').filter(Boolean); - assert(!segments.some((part) => part === '.' || part === '..' || /[%\\?#]/.test(part)), - `Unsafe or unsupported site base: ${base}`); - return segments; -} - -export async function checkLinks(output, base) { - const segments = baseSegments(base); - const files = await htmlFiles(output); - assert(files.length, 'No built HTML found for link validation.'); - await temporary('workshop-links-', async (directory) => { - const root = segments.length ? join(directory, 'root') : output; - if (segments.length) { - const target = join(root, ...segments); - await mkdir(dirname(target), { recursive: true }); - await symlink(output, target, 'dir'); - } - // Lychee expands this pattern itself; spawning without a shell preserves paths with spaces. - run('lychee', ['--offline', '--no-progress', '--root-dir', root, join(output, '**/*.html')]); - }); -} - -export async function main(args = process.argv.slice(2)) { - assert(args.length <= 1 && ['all', 'build', 'links'].includes(args[0] ?? 'all'), - 'Usage: node verify.mjs [all|build|links]'); - assert(process.platform !== 'win32', 'This workflow is tested on POSIX systems; native Windows is not supported.'); - const mode = args[0] ?? 'all'; - const website = await validateRoot(repository); - await parsers(website); // Fail before any cleanup if the declared dependencies are unavailable. - await temporary('workshop-verify-', async (directory) => { - await writeFile(join(directory, 'owner'), `${website}/`); - const env = { ...process.env, DOCS_VERIFY_CAPTURE: directory }; - const options = { cwd: website, env }; - run('npm', mode === 'links' ? ['run', 'astro', '--', 'sync'] : ['run', 'check:all'], options); - const captured = async (name) => JSON.parse(await readFile(join(directory, name), 'utf8')); - const config = await captured('config.json'); - const output = await safeOutput(website, config, mode === 'links'); - if (mode === 'links') { - await checkLinks(output, config.base); - return; - } - await rm(output, { recursive: true, force: true }); - run('npm', ['run', 'build'], options); - const builtConfig = await captured('config.json'); - assert.equal(await safeOutput(website, builtConfig, true), output); - await verifyOutput({ - entries: await captured('sources.json'), - config: builtConfig, - routes: await captured('routes.json'), - website, output, - markdownOptions: builtConfig.markdown, - }); - if (mode === 'all') await checkLinks(output, builtConfig.base); - }); -} - -if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { - main().catch((error) => { - console.error(error.stack ?? error.message); - process.exitCode = error.exitCode ?? 1; - }); -} diff --git a/.github/skills/build-and-verify-docs/scripts/verify.ps1 b/.github/skills/build-and-verify-docs/scripts/verify.ps1 new file mode 100644 index 00000000..678d906d --- /dev/null +++ b/.github/skills/build-and-verify-docs/scripts/verify.ps1 @@ -0,0 +1,53 @@ +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $false +$exitCode = 1 +$linkRoot = $null +$link = $null +$locationPushed = $false + +try { + $repo = (Resolve-Path (Join-Path $PSScriptRoot '../../../..')).Path + $website = Join-Path $repo 'website' + Push-Location -LiteralPath $website + $locationPushed = $true + $npm = if ($env:OS -eq 'Windows_NT') { 'npm.cmd' } else { 'npm' } + Get-Command $npm, lychee -ErrorAction Stop | Out-Null + if (!(Test-Path -LiteralPath 'astro.config.mjs' -PathType Leaf) -or + !(Test-Path -LiteralPath 'package.json' -PathType Leaf)) { + throw 'Expected the workshop website configuration and package.json.' + } + + & $npm run check:all + if ($LASTEXITCODE -ne 0) { $exitCode = $LASTEXITCODE; throw 'Type checks failed.' } + $dist = Join-Path $website 'dist' + if (Test-Path -LiteralPath $dist) { + $existing = Get-Item -LiteralPath $dist -Force + if (!$existing.PSIsContainer -or ($existing.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw 'Refusing to remove dist: expected a directory, not a link or file.' + } + Remove-Item -LiteralPath $dist -Recurse -Force + } + & $npm run build + if ($LASTEXITCODE -ne 0) { $exitCode = $LASTEXITCODE; throw 'Site build failed.' } + $built = Get-Item -LiteralPath $dist -Force + if (!$built.PSIsContainer -or ($built.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw 'Build did not produce a regular dist directory.' + } + + $linkRoot = Join-Path ([IO.Path]::GetTempPath()) ('workshop-links-' + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $linkRoot | Out-Null + # Match the current base in website/astro.config.mjs, as the Pages workflow does. + $link = Join-Path $linkRoot 'copilot-workshops' + $linkType = if ($env:OS -eq 'Windows_NT') { 'Junction' } else { 'SymbolicLink' } + New-Item -ItemType $linkType -Path $link -Target $dist | Out-Null + & lychee --offline --no-progress --root-dir $linkRoot (Join-Path $dist '**/*.html') + $exitCode = $LASTEXITCODE +} catch { + Write-Error $_ -ErrorAction Continue +} finally { + # Delete the link itself, never recurse into the built site through it. + if ($link -and (Test-Path -LiteralPath $link)) { [IO.Directory]::Delete($link) } + if ($linkRoot -and (Test-Path -LiteralPath $linkRoot)) { [IO.Directory]::Delete($linkRoot) } + if ($locationPushed) { Pop-Location } +} +exit $exitCode diff --git a/.github/skills/build-and-verify-docs/scripts/verify.sh b/.github/skills/build-and-verify-docs/scripts/verify.sh new file mode 100644 index 00000000..93869880 --- /dev/null +++ b/.github/skills/build-and-verify-docs/scripts/verify.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd -P) +cd -- "$repo/website" +for tool in npm lychee; do + command -v "$tool" >/dev/null || { echo "Required tool not found: $tool" >&2; exit 1; } +done +if [[ ! -f astro.config.mjs || ! -f package.json ]]; then + echo "Expected the workshop website configuration and package.json." >&2 + exit 1 +fi + +npm run check:all +if [[ -L dist || ( -e dist && ! -d dist ) ]]; then + echo "Refusing to remove dist: expected a directory, not a symlink or file." >&2 + exit 1 +fi +rm -rf -- "$PWD/dist" +npm run build +if [[ ! -d dist || -L dist ]]; then + echo "Build did not produce a regular dist directory." >&2 + exit 1 +fi + +link_root=$(mktemp -d) +trap 'status=$?; rm -f -- "$link_root/copilot-workshops"; rmdir -- "$link_root"; exit "$status"' EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +# Match the current base in website/astro.config.mjs, as the Pages workflow does. +ln -s -- "$PWD/dist" "$link_root/copilot-workshops" +lychee --offline --no-progress --root-dir "$link_root" "$PWD/dist/**/*.html" diff --git a/.github/skills/build-and-verify-docs/scripts/verify.test.mjs b/.github/skills/build-and-verify-docs/scripts/verify.test.mjs deleted file mode 100644 index 1b9ff64a..00000000 --- a/.github/skills/build-and-verify-docs/scripts/verify.test.mjs +++ /dev/null @@ -1,198 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { readFile, writeFile, mkdir, cp, symlink, readdir, lstat, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { expectedPages, compareRoutes, comparePage, extraPages, parsers, sourceContent } from './content.mjs'; -import { repository, temporary, safeOutput, validateRoot, baseSegments, run } from './verify.mjs'; - -const api = await parsers(join(repository, 'website')); -const i18n = { - defaultLocale: 'en', - locales: [{ path: 'en', codes: ['en'] }, { path: 'fr', codes: ['fr-FR'] }], - routing: { prefixDefaultLocale: false }, -}; -const entry = (id, title = id, draft = false) => ({ id, data: { title, draft } }); - -test('routes derive from slugs, locale configuration, translations and draft status', () => { - const entries = [entry('index'), entry('topic/custom'), entry('fr/topic/custom', 'Traduit'), entry('draft', '', true)]; - const pages = expectedPages(entries, i18n); - assert.equal(pages.get('index.html').lang, 'en'); - assert.equal(pages.get('fr/index.html').fallback, true); - assert.equal(pages.get('fr/topic/custom/index.html').entry.data.title, 'Traduit'); - assert.equal(pages.get('fr/topic/custom/index.html').fallback, false); - assert(!pages.has('draft/index.html')); - const expanded = expectedPages([...entries, entry('new lesson')], { - ...i18n, locales: [...i18n.locales, { path: 'de', codes: ['de-DE'] }], - }); - assert.equal(expanded.get('de/new lesson/index.html').lang, 'de-DE'); - assert.equal(expanded.get('de/new lesson/index.html').fallback, true); - assert.throws(() => expectedPages([entry('../escape')], i18n), /Unsafe route/); - assert.throws(() => expectedPages([entry('index'), entry('')], i18n), /Duplicate/); - assert.throws(() => expectedPages(entries, { ...i18n, routing: 'manual' }), /Unsupported locale/); -}); - -test('missing, removed and support routes are detected, rather than comparing totals', () => { - const pages = expectedPages([entry('index')], i18n); - const files = [...pages.keys()]; - compareRoutes(files, pages, new Set()); - assert.throws(() => compareRoutes(files.slice(1), pages, new Set()), /Missing: index.html/); - for (const extra of ['removed/index.html', '_images/index.html']) { - assert.throws(() => compareRoutes([...files, extra], pages, new Set()), /Unexpected:/); - } - assert.throws(() => compareRoutes([files[0], 'wrong/index.html'], pages, new Set()), /Missing:.*Unexpected:/s); -}); - -test('intentional static extras come from resolved routes; unknown dynamic pages fail explicitly', () => { - const collection = { - pattern: '/[...slug]', params: ['...slug'], type: 'page', isPrerendered: true, - entrypoint: '@astrojs/starlight/routes/static/index.astro', - }; - const staticPage = (pathname) => ({ pathname, params: [], type: 'page', isPrerendered: true }); - assert.deepEqual([...extraPages([collection, staticPage('/404'), staticPage('/legacy/moved')])], - ['404.html', 'legacy/moved/index.html']); - assert.throws(() => extraPages([{ ...collection, entrypoint: 'src/pages/[...slug].astro' }]), /Unsupported dynamic/); -}); - -const source = { - title: 'Fish & chips', headings: [[2, 'Read carefully']], code: ['echo "<ok>"\n\n keep spacing'], - prose: 'Read carefully Translated body.', -}; -const html = `<!doctype html><html lang="fr-FR"><head><title>Fish & chips | Site -

Fish & chips

Read carefullyPermalink

-

Translated body.

echo "<ok>"
-
\n
keep spacing
`; - -test('HTML5 parsing decodes entities and preserves exact fenced text, blank lines and indentation', () => { - comparePage(html, { lang: 'fr-FR' }, source, api, 'fixture'); -}); - -for (const [label, before, after, diagnostic] of [ - ['language', 'lang="fr-FR"', 'lang="en"', /language/], - ['title', 'Fish', '<title>Wrong', /document title/], - ['H1', '<h1>Fish', '<h1>Wrong', /H1/], - ['heading', '<h2>Read', '<h2>Wrong', /headings/], - ['fenced prompt', 'keep spacing', 'changed prompt', /code block/], - ['fenced indentation', ' keep spacing', 'keep spacing', /code block/], - ['translation content', 'Translated body.', 'English fallback.', /source prose/], - ['raw admonition', 'Translated body.', '[!TIP] Translated body.', /source prose|admonition/], -]) { - test(`rejects ${label} mismatch`, () => { - assert.throws(() => comparePage(html.replace(before, after), { lang: 'fr-FR' }, source, api, 'fixture'), diagnostic); - }); -} - -test('source frontmatter uses the public YAML parser; exact code and entity-rich headings are derived', async () => { - await temporary('workshop-source-', async (directory) => { - const website = join(directory, 'website'); - await mkdir(website); - const markdown = '---\ntitle: >-\n Fish & chips\n---\n## Read *carefully*\n\nTranslated body.\n\n```text\necho "<ok>"\n\n keep spacing\n```\n'; - await writeFile(join(directory, 'a file.md'), markdown); - const parsed = api.parseFrontmatter(markdown); - const data = { filePath: '../a file.md', body: parsed.content, data: parsed.frontmatter }; - assert.deepEqual(await sourceContent(data, website, api, {}), source); - await assert.rejects(sourceContent({ ...data, body: 'stale' }, website, api, {}), /changed since capture/); - await assert.rejects(sourceContent({ ...data, filePath: '../a.mdx' }, website, api, {}), /Unsupported source type/); - }); -}); - -test('wrong roots, unsafe output paths, symlinks, missing artifacts and unsafe base paths fail', async () => { - await temporary('workshop-safety-', async (directory) => { - await assert.rejects(validateRoot(directory), /ENOENT/); - const website = join(directory, 'website'); - await mkdir(website); - const config = { - root: pathToFileURL(`${website}/`).href, outDir: pathToFileURL(`${website}/dist/`).href, - output: 'static', format: 'directory', - }; - assert.equal(await safeOutput(website, config), join(website, 'dist')); - await assert.rejects(safeOutput(website, config, true), /ENOENT/); - await assert.rejects(safeOutput(website, { ...config, outDir: config.root }), /Refusing to clean/); - await symlink(directory, join(website, 'dist')); - await assert.rejects(safeOutput(website, config), /symlink/); - for (const base of ['../bad', '/../../', '/a\\b/', '/%2e%2e/']) assert.throws(() => baseSegments(base)); - assert.deepEqual(baseSegments('/nested/site/'), ['nested', 'site']); - assert.deepEqual(baseSegments('/'), []); - }); -}); - -test('native subprocess exit codes are preserved; missing tools fail explicitly', () => { - assert.throws(() => run(process.execPath, ['-e', 'process.exit(7)']), (error) => error.exitCode === 7); - assert.throws(() => run('/nonexistent/workshop-verification-tool', []), /Cannot run/); -}); - -test('script-owned temporary directories are cleaned on success and failure without following symlinks', async () => { - let removed; - await temporary('workshop-outer-', async (outside) => { - const sentinel = join(outside, 'keep'); - await writeFile(sentinel, 'unchanged'); - await assert.rejects(temporary('workshop-inner-', async (directory) => { - removed = directory; - await symlink(outside, join(directory, 'link')); - throw new Error('deliberate failure'); - }), /deliberate/); - assert.equal(await readFile(sentinel, 'utf8'), 'unchanged'); - }); - await assert.rejects(lstat(removed), /ENOENT/); -}); - -test('real entry point builds dynamic lesson/locale fixtures from another cwd with spaces', { timeout: 600000 }, async () => { - await mkdir(join(repository, 'website/.astro'), { recursive: true }); - await temporary('workshop fixture ', async (fixture) => { - const website = join(fixture, 'website'); - const scripts = join(fixture, '.github/skills/build-and-verify-docs/scripts'); - await mkdir(join(website, 'src'), { recursive: true }); - await mkdir(join(fixture, 'docs/_support'), { recursive: true }); - await mkdir(join(fixture, 'temp area')); - await cp(join(repository, '.github/skills/build-and-verify-docs/scripts'), scripts, { recursive: true }); - for (const file of ['package.json', 'tsconfig.json', 'tsconfig.tsgo.json', 'src/content.config.ts']) { - await cp(join(repository, 'website', file), join(website, file)); - } - await cp(join(repository, 'website/src/pages'), join(website, 'src/pages'), { recursive: true }); - await cp(join(repository, 'website/public'), join(website, 'public'), { recursive: true }); - await symlink(join(repository, 'website/node_modules'), join(website, 'node_modules'), 'dir'); - const config = (locales) => ` -import { defineConfig } from 'astro/config'; -import starlight from '@astrojs/starlight'; -import { unified } from '@astrojs/markdown-remark'; -import { verificationIntegration } from '../.github/skills/build-and-verify-docs/scripts/capture.mjs'; -export default defineConfig({ site: 'https://example.test', base: '/nested/workshop', trailingSlash: 'always', - markdown: { processor: unified() }, - integrations: [verificationIntegration(), starlight({title: 'Fixture', locales: ${JSON.stringify(locales)}})] }); -`; - const locales = { root: { label: 'English', lang: 'en' }, fr: { label: 'French', lang: 'fr-FR' } }; - await writeFile(join(website, 'astro.config.mjs'), config(locales)); - await writeFile(join(fixture, 'docs/README.md'), '---\ntitle: Home\nslug: index\n---\n## Start\n\nWelcome.\n'); - await writeFile(join(fixture, 'docs/a file.md'), '---\ntitle: Lesson\nslug: custom-route\n---\n## Task\n\nDo this.\n\n```text\nprompt & <literal>\n\n indentation\n```\n'); - await writeFile(join(fixture, 'docs/_support/hidden.md'), '---\ntitle: Must not be routed\n---\n'); - const invoke = (mode, expectedStatus = 0) => { - const started = performance.now(); - const result = spawnSync(process.execPath, [join(scripts, 'verify.mjs'), mode], { - cwd: join(fixture, 'temp area'), encoding: 'utf8', maxBuffer: 5_000_000, - env: { ...process.env, TMPDIR: join(fixture, 'temp area') }, - }); - assert.equal(result.status, expectedStatus, `${result.stdout}\n${result.stderr}`); - console.log(`Fixture ${mode}: exit ${result.status}, ${Math.round(performance.now() - started)} ms`); - return result.stdout; - }; - assert.match(invoke('build'), /Verified .* source-derived pages/); - assert((await lstat(join(website, 'dist/fr/custom-route/index.html'))).isFile()); - await mkdir(join(fixture, 'docs/fr')); - await writeFile(join(fixture, 'docs/fr/a file.md'), '---\ntitle: Leçon\nslug: fr/custom-route\n---\n## Travail\n\nTexte traduit.\n'); - await writeFile(join(fixture, 'docs/new lesson.md'), '---\ntitle: New lesson\n---\n## New task\n\nNew work.\n'); - await writeFile(join(website, 'astro.config.mjs'), config({ ...locales, de: { label: 'German', lang: 'de-DE' } })); - assert.match(invoke('all'), /Verified .* source-derived pages/); - const translated = await readFile(join(website, 'dist/fr/custom-route/index.html'), 'utf8'); - assert.match(translated, /Texte traduit/); - assert((await lstat(join(website, 'dist/de/new-lesson/index.html'))).isFile()); - await writeFile(join(website, 'dist/fr/custom-route/index.html'), - translated.replace('</body>', '<a href="/nested/workshop/missing/">Broken</a></body>')); - invoke('links', 2); - assert.deepEqual((await readdir(join(fixture, 'temp area'))).filter((name) => name.startsWith('workshop-')), []); - // Removing the source must also remove its old output on the next clean build. - await rm(join(fixture, 'docs/new lesson.md')); - invoke('build'); - await assert.rejects(lstat(join(website, 'dist/de/new-lesson/index.html')), /ENOENT/); - }, join(repository, 'website/.astro')); -}); diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 8c6d4679..2abc22db 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -2,7 +2,6 @@ import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; import remarkGithubAdmonitionsToDirectives from 'remark-github-admonitions-to-directives'; -import { verificationIntegration } from '../.github/skills/build-and-verify-docs/scripts/capture.mjs'; // Lesson callouts are authored in GitHub admonition syntax (`> [!NOTE]`). This // remark plugin rewrites them into Starlight aside directives before Starlight @@ -28,7 +27,6 @@ export default defineConfig({ ], }, integrations: [ - verificationIntegration(), starlight({ title: 'Copilot Workshops', description: diff --git a/website/package-lock.json b/website/package-lock.json index f30b85ae..54dd315f 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -17,7 +17,6 @@ }, "devDependencies": { "@typescript/native-preview": "^7.0.0-dev.20260707.2", - "hast-util-from-html": "2.0.3", "remark-github-admonitions-to-directives": "^2.1.0" } }, diff --git a/website/package.json b/website/package.json index 95faed74..0a82e4ae 100644 --- a/website/package.json +++ b/website/package.json @@ -23,7 +23,6 @@ }, "devDependencies": { "@typescript/native-preview": "^7.0.0-dev.20260707.2", - "hast-util-from-html": "2.0.3", "remark-github-admonitions-to-directives": "^2.1.0" }, "allowScripts": { diff --git a/website/src/content.config.ts b/website/src/content.config.ts index 94e6e24b..ba0a5873 100644 --- a/website/src/content.config.ts +++ b/website/src/content.config.ts @@ -2,7 +2,6 @@ import { defineCollection } from 'astro:content'; import { glob } from 'astro/loaders'; import { docsSchema } from '@astrojs/starlight/schema'; import { z } from 'astro:schema'; -import { captureLoader } from '../../.github/skills/build-and-verify-docs/scripts/capture.mjs'; // We use Astro's `glob()` loader directly (instead of Starlight's `docsLoader()`) // so we can exclude underscore-prefixed *directories* like `_images/`. Starlight's @@ -17,10 +16,10 @@ import { captureLoader } from '../../.github/skills/build-and-verify-docs/script // fields pre-stage the metadata the awesome-copilot Learning Hub mirror expects. export const collections = { docs: defineCollection({ - loader: captureLoader(glob({ + loader: glob({ base: '../docs', pattern: ['**/*.{md,mdx}', '!**/_*/**', '!**/_*'], - })), + }), schema: docsSchema({ extend: z.object({ authors: z.array(z.string()).optional(),