From 74bdbc193c06455d363c71ecae7ef476156a9649 Mon Sep 17 00:00:00 2001 From: Pierre Malarme Date: Wed, 8 Jul 2026 16:03:17 +0200 Subject: [PATCH 1/2] Rebase relative links so source step docs work on GitHub Source step docs and partials authored their relative links/images repo-root-relative, so they resolved in the rendered README but 404'd when the source doc was viewed directly on GitHub. Author them source-relative instead and rebase to repo-root-relative at render time. - render_readme.py: add rebase_relative_links() + is_rebasable_url() / resolve_relative_target() helpers; rebase step bodies and partials when composing README.md. Absolute URLs, anchors, and placeholder URLs pass through unchanged; rendered README is byte-identical. - Convert all step docs and partials to source-relative links/images. - lint_steps.py: add check K rejecting root-relative links and any link/image whose target does not exist. - Add renderer + lint tests (rebasing, absolute/anchor pass-through, image paths, root-relative and broken-link rejection). - Update workshop.instructions.md to document the new convention. Fixes #7 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/instructions/workshop.instructions.md | 28 +++--- .workshop/docs/partials/_header.md | 2 +- .workshop/docs/partials/_push_to_advance.md | 2 +- .workshop/docs/partials/_start_button.md | 2 +- .workshop/docs/steps/01-basic.md | 10 +- .workshop/docs/steps/02-tools.md | 4 +- .workshop/docs/steps/03-mcp.md | 4 +- .workshop/docs/steps/04-toolbox.md | 6 +- .workshop/docs/steps/05-rag.md | 2 +- .workshop/docs/steps/06-skills.md | 6 +- .workshop/docs/steps/07-multi-agent.md | 2 +- .workshop/docs/steps/08-workflow.md | 2 +- .workshop/docs/steps/09-memory.md | 2 +- .workshop/scripts/lint_steps.py | 68 ++++++++++++- .workshop/scripts/render_readme.py | 89 ++++++++++++++++- .workshop/scripts/tests/test_lint_steps.py | 75 ++++++++++++++ .workshop/scripts/tests/test_render_readme.py | 97 +++++++++++++++++++ 17 files changed, 363 insertions(+), 38 deletions(-) create mode 100644 .workshop/scripts/tests/test_lint_steps.py diff --git a/.github/instructions/workshop.instructions.md b/.github/instructions/workshop.instructions.md index 43994cb..2c58e73 100644 --- a/.github/instructions/workshop.instructions.md +++ b/.github/instructions/workshop.instructions.md @@ -45,18 +45,22 @@ and `python -m pytest .workshop/scripts/tests`. add them to later `.workshop/step_files/NN`. - **Keep it teaching-quality.** Never minify code in `.workshop/step_files/` or `.workshop/solutions/` — keep it clean and expanded (KISS). -- **Links and images in step docs are repo-root-relative.** Step docs render - into the participant's **root** `README.md`, so author relative links and - image paths as they resolve *from the repo root* — e.g. - `.workshop/docs/assets/NN-*.png` and `.workshop/solutions/NN-*/`, not - `docs/assets/...` or `../assets/...`. Consequence: viewing a source step doc - directly on GitHub (under `.workshop/docs/steps/`) shows those relative links - as broken — that is expected; the **rendered `README.md` is the canonical - artifact**. For links that must leave the docs, use absolute URLs: - `https://github.com/{{OWNER}}/{{REPO}}/...` when the link must target the - participant's **own** instance (e.g. their Actions workflows), and a hardcoded - `https://github.com/Azure-Samples/foundry-hosted-agents-workshop/...` when it must reach - the **template/upstream** repo (e.g. the workshop-feedback issue template). +- **Links and images in step docs and partials are source-relative.** Author + relative links and image paths **relative to the folder the source file lives + in**, so they resolve when the file is viewed directly on GitHub. From a step + doc under `.workshop/docs/steps/`, that means `../assets/NN-*.png`, + `../../solutions/NN-*/`, and `NN-intro.md#anchor` for a sibling step; from a + partial under `.workshop/docs/partials/`, use `../steps/00-intro.md#anchor`. + The renderer (`render_readme.py`) rebases these to repo-root-relative when it + inlines them into the root `README.md`, so both the source doc and the rendered + README have working links. `lint_steps.py` check **K** rejects root-relative + links and any link/image whose target doesn't exist. For links that must leave + the docs, use absolute URLs: `https://github.com/{{OWNER}}/{{REPO}}/...` when + the link must target the participant's **own** instance (e.g. their Actions + workflows), and a hardcoded + `https://github.com/Azure-Samples/foundry-hosted-agents-workshop/...` when it + must reach the **template/upstream** repo (e.g. the workshop-feedback issue + template). ## Before you open a PR diff --git a/.workshop/docs/partials/_header.md b/.workshop/docs/partials/_header.md index 26c34cd..17be254 100644 --- a/.workshop/docs/partials/_header.md +++ b/.workshop/docs/partials/_header.md @@ -14,4 +14,4 @@ Built on the upstream [foundry-samples](https://github.com/microsoft-foundry/fou -If something looks broken see [Troubleshooting](.workshop/docs/steps/00-intro.md#troubleshooting). +If something looks broken see [Troubleshooting](../steps/00-intro.md#troubleshooting). diff --git a/.workshop/docs/partials/_push_to_advance.md b/.workshop/docs/partials/_push_to_advance.md index a8059a2..70a9264 100644 --- a/.workshop/docs/partials/_push_to_advance.md +++ b/.workshop/docs/partials/_push_to_advance.md @@ -19,6 +19,6 @@ After the **Advance workshop on push to main** Action finishes, run **`git pull` > Each push to `main` advances the workshop by exactly **one** step, so push once — when this step is done. -> **Prefer to stay local?** Run `python .workshop/scripts/advance_step.py --expected-current-step {{CURRENT_STEP}} --auto-commit` (or `make advance`) instead. That advances locally and records it in the same commit, so your next push won't advance again. See [Working fully locally](.workshop/docs/steps/00-intro.md#5-working-fully-locally-no-github-actions). +> **Prefer to stay local?** Run `python .workshop/scripts/advance_step.py --expected-current-step {{CURRENT_STEP}} --auto-commit` (or `make advance`) instead. That advances locally and records it in the same commit, so your next push won't advance again. See [Working fully locally](../steps/00-intro.md#5-working-fully-locally-no-github-actions). Made a mistake? Use the [Reset workshop](https://github.com/{{OWNER}}/{{REPO}}/actions/workflows/reset-workshop.yml) workflow, or run `python .workshop/scripts/advance_step.py --reset --auto-commit` locally. diff --git a/.workshop/docs/partials/_start_button.md b/.workshop/docs/partials/_start_button.md index 27bd811..adf1286 100644 --- a/.workshop/docs/partials/_start_button.md +++ b/.workshop/docs/partials/_start_button.md @@ -15,6 +15,6 @@ From Step {{NEXT_STEP_NUMBER}} onward you don't click a button to advance — yo > 💡 **Button returns 404?** Your repo's one-time **Initialize workshop** Action hasn't run yet. Open the **Actions** tab, run **Initialize workshop → Run workflow**, then refresh this page. -> **Prefer to stay local?** Run `python .workshop/scripts/advance_step.py --expected-current-step {{CURRENT_STEP}} --auto-commit` (or `make advance`) instead of clicking the button. See [Working fully locally](.workshop/docs/steps/00-intro.md#5-working-fully-locally-no-github-actions) for the full local flow. +> **Prefer to stay local?** Run `python .workshop/scripts/advance_step.py --expected-current-step {{CURRENT_STEP}} --auto-commit` (or `make advance`) instead of clicking the button. See [Working fully locally](../steps/00-intro.md#5-working-fully-locally-no-github-actions) for the full local flow. Made a mistake? Use the [Reset workshop](https://github.com/{{OWNER}}/{{REPO}}/actions/workflows/reset-workshop.yml) workflow, or run `python .workshop/scripts/advance_step.py --reset --auto-commit` locally. diff --git a/.workshop/docs/steps/01-basic.md b/.workshop/docs/steps/01-basic.md index 38fbf0d..84de7d2 100644 --- a/.workshop/docs/steps/01-basic.md +++ b/.workshop/docs/steps/01-basic.md @@ -20,7 +20,7 @@ In this step you **complete** the one small edit called out below (TravelBuddy's instructions in `main.py`) — you don't create the files from scratch, and the two YAML files are ready to use as-is. The workshop is **incremental**: when you advance, the next step's files are laid on top of your `travel_assistant/` folder. Files from earlier steps that the next step doesn't touch stay exactly as they are — nothing is deleted. Files the next step ships (for example an updated `main.py`) are refreshed to that step's version, and your current work is backed up under `.workshop_instance/workshop_backups/step-/` first, so you can always recover your own wording. -> **Before you start:** make sure you completed the setup in Step 0 — [Install the tools you'll need](.workshop/docs/steps/00-intro.md#install-the-tools-youll-need) and [Set up your local environment](.workshop/docs/steps/00-intro.md#set-up-your-local-environment-one-time). If `python .workshop/scripts/preflight.py --step 1` is green, you're ready. +> **Before you start:** make sure you completed the setup in Step 0 — [Install the tools you'll need](00-intro.md#install-the-tools-youll-need) and [Set up your local environment](00-intro.md#set-up-your-local-environment-one-time). If `python .workshop/scripts/preflight.py --step 1` is green, you're ready. ## Concept (5-min read) @@ -98,7 +98,7 @@ Open `travel_assistant/agent.manifest.yaml` and read through it — the inline c **What happens at run/deploy time:** locally, this process serves an OpenAI-compatible Responses endpoint on `http://localhost:8088`. After deployment, Foundry starts the same code as the hosted container entry point. -Open `travel_assistant/main.py` and complete the `TODO`. If you get stuck, the finished file is in [`.workshop/solutions/01-basic/`](.workshop/solutions/01-basic/). +Open `travel_assistant/main.py` and complete the `TODO`. If you get stuck, the finished file is in [`.workshop/solutions/01-basic/`](../../solutions/01-basic/). `ResponsesHostServer` is the hosted-agent contract: when started locally it serves an OpenAI-compatible Responses endpoint on `http://localhost:8088`; when packaged and deployed to Foundry it becomes the container entry point. The same code runs in both places. @@ -227,7 +227,7 @@ You can run and deploy TravelBuddy two ways: with the [**Azure Developer CLI** ( Prefer a UI? With the local agent still running, open the **Agent Inspector** from the Foundry Toolkit (Command Palette → **Foundry Toolkit: Open Agent Inspector**, or the **Agent Inspector** entry under **Developer Tools**). It connects to `http://localhost:8088` and lets you chat with TravelBuddy and watch the streamed Responses events. - ![Foundry Toolkit Agent Inspector connected to the local TravelBuddy agent on localhost:8088, showing the Playground chat and streamed response events](.workshop/docs/assets/01-agent-inspector.png) + ![Foundry Toolkit Agent Inspector connected to the local TravelBuddy agent on localhost:8088, showing the Playground chat and streamed response events](../assets/01-agent-inspector.png) 5. **Deploy to Foundry**. Subsequent workshop steps only need `azd deploy`: @@ -247,7 +247,7 @@ You can run and deploy TravelBuddy two ways: with the [**Azure Developer CLI** ( Prefer a UI? Open the **Hosted Agent Playground** from the Foundry Toolkit (under **Developer Tools** → **Build** → **Hosted Agent Playground**). Pick your deployed agent and version, then chat with TravelBuddy and inspect session details, logs, and traces directly in VS Code. - ![Foundry Toolkit Hosted Agent Playground with the deployed TravelBuddy agent selected, showing the Playground chat and session details panel](.workshop/docs/assets/01-hosted-agent-playground.png) + ![Foundry Toolkit Hosted Agent Playground with the deployed TravelBuddy agent selected, showing the Playground chat and session details panel](../assets/01-hosted-agent-playground.png) When you select **Hosted Agent Playground** in the Foundry Toolkit's **Developer Tools**, you'll be prompted to sign in. If the interactive sign-in doesn't complete, cancel it and choose the **device code** flow instead. Once signed in, select the Foundry project that hosts your deployed agent. @@ -280,7 +280,7 @@ Whichever option you picked, try a few prompts: ## Solution -> If you get stuck: [`.workshop/solutions/01-basic/`](.workshop/solutions/01-basic/) +> If you get stuck: [`.workshop/solutions/01-basic/`](../../solutions/01-basic/) ## Upstream sample diff --git a/.workshop/docs/steps/02-tools.md b/.workshop/docs/steps/02-tools.md index f8bf1ba..0fd1abb 100644 --- a/.workshop/docs/steps/02-tools.md +++ b/.workshop/docs/steps/02-tools.md @@ -191,7 +191,7 @@ Then update the `Agent(...)` call. **Keep your Step 1 instructions exactly as th ) ``` -That's the whole change: the `from tools import ...` line and the `tools=[...]` argument do the wiring; the appended instructions just tell the model when to reach for the tools. Everything else in `main.py` is unchanged from Step 1. If you get stuck, the finished file is in [`.workshop/solutions/02-tools/`](.workshop/solutions/02-tools/). +That's the whole change: the `from tools import ...` line and the `tools=[...]` argument do the wiring; the appended instructions just tell the model when to reach for the tools. Everything else in `main.py` is unchanged from Step 1. If you get stuck, the finished file is in [`.workshop/solutions/02-tools/`](../../solutions/02-tools/). ### 3. Update the metadata in `travel_assistant/agent.manifest.yaml` @@ -346,7 +346,7 @@ For the comparison prompt, the model may call `get_weather` once per city. ## Solution -> If you get stuck: [`.workshop/solutions/02-tools/`](.workshop/solutions/02-tools/) +> If you get stuck: [`.workshop/solutions/02-tools/`](../../solutions/02-tools/) ## Upstream sample diff --git a/.workshop/docs/steps/03-mcp.md b/.workshop/docs/steps/03-mcp.md index 28bd716..e6c3f27 100644 --- a/.workshop/docs/steps/03-mcp.md +++ b/.workshop/docs/steps/03-mcp.md @@ -113,7 +113,7 @@ Then **keep your Step 2 instructions exactly as they are** and append one MCP se tools=tools, # <-- the list you just extended above ``` -That's the whole code change. `client.get_mcp_tool(...)` reads the label and URL from the environment (the same values you just added to `.env`) and hands the agent a remote tool. Everything else in `main.py` — the `FoundryChatClient` setup, the three function tools, `default_options={"store": False}`, and `ResponsesHostServer(agent).run()` — is unchanged from Step 2. If you get stuck, the finished file is in [`.workshop/solutions/03-mcp/`](.workshop/solutions/03-mcp/). +That's the whole code change. `client.get_mcp_tool(...)` reads the label and URL from the environment (the same values you just added to `.env`) and hands the agent a remote tool. Everything else in `main.py` — the `FoundryChatClient` setup, the three function tools, `default_options={"store": False}`, and `ResponsesHostServer(agent).run()` — is unchanged from Step 2. If you get stuck, the finished file is in [`.workshop/solutions/03-mcp/`](../../solutions/03-mcp/). > **Why `os.environ[...]` and not a hardcoded URL?** Reading the label and URL from the environment keeps them out of source control and lets you point at a different MCP server (or the authenticated variant in Troubleshooting) by editing `.env` — no code change. The hosted runtime gets the same values from the manifest at deploy time. @@ -338,7 +338,7 @@ Make the prompt explicit: give an origin, a destination (IATA codes work well), ## Solution -> If you get stuck: [`.workshop/solutions/03-mcp/`](.workshop/solutions/03-mcp/) +> If you get stuck: [`.workshop/solutions/03-mcp/`](../../solutions/03-mcp/) ## Upstream sample diff --git a/.workshop/docs/steps/04-toolbox.md b/.workshop/docs/steps/04-toolbox.md index e553085..bd331f4 100644 --- a/.workshop/docs/steps/04-toolbox.md +++ b/.workshop/docs/steps/04-toolbox.md @@ -151,7 +151,7 @@ TOOLBOX_ENDPOINT=https://.services.ai.azure.com/api/projects// Once the toolbox is deployed, you can also see it in the **Foundry Toolkit** for VS Code: expand your project under **My Resources → Tools** and open the **Toolboxes** tab to view your toolbox along with its **Endpoint URL**. -![Foundry Toolkit for VS Code showing the deployed toolbox under My Resources → Tools, with the Toolboxes tab listing the travel toolbox and its Endpoint URL](.workshop/docs/assets/04-toolbox-toolkit.png) +![Foundry Toolkit for VS Code showing the deployed toolbox under My Resources → Tools, with the Toolboxes tab listing the travel toolbox and its Endpoint URL](../assets/04-toolbox-toolkit.png) ### 3. Swap the MCP tool for the toolbox in `travel_assistant/main.py` @@ -221,7 +221,7 @@ and put the toolbox sentence in its place: ), ``` -That's the whole code change. Everything else — the `FoundryChatClient` setup, the three function tools, `default_options={"store": False}`, and the synchronous `ResponsesHostServer(agent).run()` — is unchanged from Step 3. If you get stuck, the finished file is in [`.workshop/solutions/04-toolbox/`](.workshop/solutions/04-toolbox/). +That's the whole code change. Everything else — the `FoundryChatClient` setup, the three function tools, `default_options={"store": False}`, and the synchronous `ResponsesHostServer(agent).run()` — is unchanged from Step 3. If you get stuck, the finished file is in [`.workshop/solutions/04-toolbox/`](../../solutions/04-toolbox/). > **Why one `credential`?** The toolbox and the chat client both authenticate to Foundry. Sharing a single `DefaultAzureCredential()` avoids two sign-in flows and keeps token caching in one place. @@ -461,7 +461,7 @@ The final `tools` list should contain **four** entries: the three function tools ## Solution -> If you get stuck: [`.workshop/solutions/04-toolbox/`](.workshop/solutions/04-toolbox/) +> If you get stuck: [`.workshop/solutions/04-toolbox/`](../../solutions/04-toolbox/) ## Upstream sample diff --git a/.workshop/docs/steps/05-rag.md b/.workshop/docs/steps/05-rag.md index f80cd39..65ad84c 100644 --- a/.workshop/docs/steps/05-rag.md +++ b/.workshop/docs/steps/05-rag.md @@ -572,7 +572,7 @@ You can also create both from the **Azure portal**. See the links under **Learn ## Solution -> If you get stuck: [`.workshop/solutions/05-rag/`](.workshop/solutions/05-rag/) +> If you get stuck: [`.workshop/solutions/05-rag/`](../../solutions/05-rag/) ## Upstream sample diff --git a/.workshop/docs/steps/06-skills.md b/.workshop/docs/steps/06-skills.md index d8caa24..8212884 100644 --- a/.workshop/docs/steps/06-skills.md +++ b/.workshop/docs/steps/06-skills.md @@ -330,7 +330,7 @@ resources: [] Prefer a UI? Open the **Hosted Agent Playground** from the Foundry Toolkit (**Developer Tools** → **Build** → **Hosted Agent Playground**), pick your deployed agent and version, and ask for a guide — the generated PDF shows up under **Session Details → Files**. - ![Foundry Toolkit Hosted Agent Playground with the deployed TravelBuddy agent, showing the run_skill_script tool call and the generated reykjavik-3-day-travel-guide.pdf listed under Session Details → Files](.workshop/docs/assets/06-travel-guide-skill-playground.png) + ![Foundry Toolkit Hosted Agent Playground with the deployed TravelBuddy agent, showing the run_skill_script tool call and the generated reykjavik-3-day-travel-guide.pdf listed under Session Details → Files](../assets/06-travel-guide-skill-playground.png) --- @@ -404,7 +404,7 @@ It prints the uploaded skill's version and `skill_id`, then confirms the project Prefer a UI? Open the Foundry Toolkit and select your project under **My Resources → Tools → Skills** — the uploaded `response-guardrails` skill appears there with its version and description. -![Foundry Toolkit Skills tab under My Resources → Tools, listing the uploaded response-guardrails skill with its Responsible-AI description and version v1](.workshop/docs/assets/06-foundry-skill-provisioned.png) +![Foundry Toolkit Skills tab under My Resources → Tools, listing the uploaded response-guardrails skill with its Responsible-AI description and version v1](../assets/06-foundry-skill-provisioned.png) ### B2. Add the download and extend the provider in `main.py` @@ -727,7 +727,7 @@ That folder is a **runtime cache** in the OS temp dir — `main.py` deletes and ## Solution -- [`.workshop/solutions/06-skills/`](.workshop/solutions/06-skills/) — the local skill **and** the required Foundry skill, with `foundry_skills/` as a sibling of `travel_assistant/`. +- [`.workshop/solutions/06-skills/`](../../solutions/06-skills/) — the local skill **and** the required Foundry skill, with `foundry_skills/` as a sibling of `travel_assistant/`. ## Upstream sample diff --git a/.workshop/docs/steps/07-multi-agent.md b/.workshop/docs/steps/07-multi-agent.md index c958b36..cb9906d 100644 --- a/.workshop/docs/steps/07-multi-agent.md +++ b/.workshop/docs/steps/07-multi-agent.md @@ -223,7 +223,7 @@ Keep import names aligned with the files you created in Steps 5–6. `coordinato ## Solution -> If you get stuck: [`.workshop/solutions/07-multi-agent/`](.workshop/solutions/07-multi-agent/) +> If you get stuck: [`.workshop/solutions/07-multi-agent/`](../../solutions/07-multi-agent/) ## Upstream sample diff --git a/.workshop/docs/steps/08-workflow.md b/.workshop/docs/steps/08-workflow.md index d10cbc3..1232156 100644 --- a/.workshop/docs/steps/08-workflow.md +++ b/.workshop/docs/steps/08-workflow.md @@ -388,7 +388,7 @@ Hosted workflow-agents surface `request_info` as a **function call**, not a prom ## Solution -> If you get stuck: [`.workshop/solutions/08-workflow/`](.workshop/solutions/08-workflow/) +> If you get stuck: [`.workshop/solutions/08-workflow/`](../../solutions/08-workflow/) ## Upstream sample diff --git a/.workshop/docs/steps/09-memory.md b/.workshop/docs/steps/09-memory.md index cb1ca8f..7a6d5a4 100644 --- a/.workshop/docs/steps/09-memory.md +++ b/.workshop/docs/steps/09-memory.md @@ -305,7 +305,7 @@ If cleanup lists it as skipped, the name has no prefix match — delete it manua ## Solution -> If you get stuck: [`.workshop/solutions/09-memory/`](.workshop/solutions/09-memory/) +> If you get stuck: [`.workshop/solutions/09-memory/`](../../solutions/09-memory/) ## Upstream sample diff --git a/.workshop/scripts/lint_steps.py b/.workshop/scripts/lint_steps.py index 33ddc2a..ca4fe07 100644 --- a/.workshop/scripts/lint_steps.py +++ b/.workshop/scripts/lint_steps.py @@ -4,6 +4,7 @@ import argparse import json +import posixpath import re import sys from dataclasses import asdict, dataclass @@ -12,7 +13,16 @@ sys.path.insert(0, str(Path(__file__).parent)) -from render_readme import FINAL_STEP, STEP_TITLES, parse_step_marker, render +from render_readme import ( + FINAL_STEP, + PARTIALS_BASE, + STEP_TITLES, + STEPS_BASE, + _LINK_RE, + parse_step_marker, + render, + resolve_relative_target, +) SCRIPT_DIR = Path(__file__).parent # This script lives at .workshop/scripts/, so the repo root is two levels up and @@ -151,6 +161,53 @@ def _scan_stray_placeholders() -> list[str]: return issues +def _source_link_failures() -> list[str]: + """Flag relative links/images in source docs that break when viewed directly. + + Source step docs and partials must author relative links/images *relative to + their own folder* so they resolve when the file is browsed directly on GitHub + (the renderer rebases them to repo-root-relative for ``README.md``). A link is + a failure when, interpreted that way, its target does not exist — either + because it was authored root-relative (the old convention) or because the + target is simply missing (e.g. a stale image path). + """ + + failures: list[str] = [] + scan = [ + (WORKSHOP_DIR / "docs" / "steps", STEPS_BASE), + (WORKSHOP_DIR / "docs" / "partials", PARTIALS_BASE), + ] + + for directory, base in scan: + if not directory.exists(): + continue + + for path in sorted(directory.glob("*.md")): + lines = path.read_text(encoding="utf-8").splitlines() + for line_number, line in enumerate(lines, start=1): + for match in _LINK_RE.finditer(line): + url = match.group("url") + target = resolve_relative_target(url, base) + if target is None or (REPO_ROOT / target).exists(): + continue + + location = f"{_relative(path)}:{line_number}" + root_target = posixpath.normpath(url.partition("#")[0]) + if (REPO_ROOT / root_target).exists(): + failures.append( + f"{location}: root-relative link '{url}' — author it " + "source-relative (source docs resolve links from the " + "doc's own folder when viewed on GitHub)" + ) + else: + failures.append( + f"{location}: broken link '{url}' — target not found " + f"(resolved to '{target}')" + ) + + return failures + + def _add_result( results: list[CheckResult], check_id: str, @@ -333,6 +390,15 @@ def run_checks() -> tuple[list[CheckResult], list[str]]: "no stray owner/repo placeholders outside GitHub URLs", ) + link_failures = _source_link_failures() + _add_result( + results, + "K", + "Source-doc relative links resolve (source-relative, no broken targets)", + link_failures, + "source-doc relative links/images resolve from the doc's own folder", + ) + return results, warnings diff --git a/.workshop/scripts/render_readme.py b/.workshop/scripts/render_readme.py index caf291c..0128b07 100644 --- a/.workshop/scripts/render_readme.py +++ b/.workshop/scripts/render_readme.py @@ -9,6 +9,7 @@ import argparse import glob +import posixpath import re import subprocess import sys @@ -18,6 +19,11 @@ REPO_ROOT = Path(__file__).resolve().parents[2] PARTIALS_DIR = REPO_ROOT / ".workshop" / "docs" / "partials" STEPS_DIR = REPO_ROOT / ".workshop" / "docs" / "steps" +# The folders that source docs live in, as repo-root-relative POSIX paths. These +# are the bases the link rebaser rewrites *from*. They are captured at import so +# tests that monkeypatch ``STEPS_DIR`` to a scratch folder don't disturb them. +STEPS_BASE = STEPS_DIR.relative_to(REPO_ROOT).as_posix() +PARTIALS_BASE = PARTIALS_DIR.relative_to(REPO_ROOT).as_posix() TERMINAL_STEP = 9 FINAL_STEP = 99 @@ -40,6 +46,81 @@ _PLACEHOLDER_OWNER = "{{OWNER}}" _PLACEHOLDER_REPO = "{{REPO}}" +# Matches Markdown inline links and images: ``[text](url)`` and ``![alt](url)``. +# The URL is captured up to whitespace or the closing paren; an optional +# ``"title"`` after the URL is captured separately in ``tail`` and preserved. +# Reference-style links, autolinks (````), and code spans are +# intentionally not matched — none carry rebasable relative paths in our docs. +_LINK_RE = re.compile( + r"(?P