diff --git a/.github/workflows/markdown-verification.yml b/.github/workflows/markdown-verification.yml index 1309386aa..95f11510e 100644 --- a/.github/workflows/markdown-verification.yml +++ b/.github/workflows/markdown-verification.yml @@ -33,12 +33,12 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Check links in Documentation - uses: JustinBeckwith/linkinator-action@v1 + - name: Setup Node + uses: actions/setup-node@v4 with: - paths: 'Documentation/**/*.md' - markdown: true - recurse: true - redirects: allow - statusCodes: '403:ok' - linksToSkip: '^(https?:\/\/)?(localhost|127\.0\.0\.1)(:\d+)?(\/|$)' + node-version: 23.x + + # The same script contributors run locally, so that what CI checks and + # what verify-markdown.sh checks cannot drift apart. + - name: Check links in Documentation + run: ./Documentation/verify-links.sh diff --git a/Documentation/backend/chronicle/index.md b/Documentation/backend/chronicle/index.md index 28a04a267..3b9378978 100644 --- a/Documentation/backend/chronicle/index.md +++ b/Documentation/backend/chronicle/index.md @@ -45,7 +45,7 @@ Without this package, Arc and Chronicle are independent. With it: | Topic | Description | | ----- | ----------- | | [Aggregates](aggregates/index.md) | Working with aggregate roots and event sourcing. | -| [Add event sourcing to an Arc slice](add-event-sourcing.md) | Move one database-backed slice to Chronicle while keeping its query and React screen in place. | +| [Add event sourcing to an Arc slice](add-event-sourcing.mdx) | Move one database-backed slice to Chronicle while keeping its query and React screen in place. | | [Cratis Package](cratis-package.md) | The convenience package for Arc + Chronicle applications. | | [React to an event](react-to-an-event.md) | Run side effects or follow-up commands from Chronicle events with reactors. | | [Commands](commands/index.md) | Returning events from commands, event source id resolution, and concurrency scoping. | diff --git a/Documentation/backend/chronicle/react-to-an-event.md b/Documentation/backend/chronicle/react-to-an-event.md index 0485f485e..5ba1e88e7 100644 --- a/Documentation/backend/chronicle/react-to-an-event.md +++ b/Documentation/backend/chronicle/react-to-an-event.md @@ -42,5 +42,5 @@ A reactor may be called more than once for the same event — during replay or r ## See also - [Returning Commands as Side Effects](./reactors/toc.yml) — execute commands as side effects from a reactor. -- [Add event sourcing to an Arc slice](./add-event-sourcing.md) — where reactors enter the Arc model. +- [Add event sourcing to an Arc slice](./add-event-sourcing.mdx) — where reactors enter the Arc model. - [Return a result or an error](/arc/scenarios/return-a-result-or-error/) — what the command you execute can return. diff --git a/Documentation/backend/getting-started/index.md b/Documentation/backend/getting-started/index.md index 5018935e4..634728206 100644 --- a/Documentation/backend/getting-started/index.md +++ b/Documentation/backend/getting-started/index.md @@ -7,7 +7,7 @@ Get an Arc backend up and building features. These first pages use a plain datab Once you have a project: -- **[Your first command and query](./your-first-command.md)** — build a backend slice end to end: a command with `Handle()`, the read model it writes, and the live query that serves it. +- **[Your first command and query](./your-first-command.mdx)** — build a backend slice end to end: a command with `Handle()`, the read model it writes, and the live query that serves it. - **[MongoDB integration](../mongodb/index.md)** — configure Arc over MongoDB collections and observable change streams. - **[Entity Framework integration](../entity-framework/getting-started.md)** — configure Arc over DbContexts and observed DbSets. diff --git a/Documentation/backend/index.md b/Documentation/backend/index.md index 23547eb3d..43a009dec 100644 --- a/Documentation/backend/index.md +++ b/Documentation/backend/index.md @@ -46,5 +46,5 @@ Arc meets the rest of your stack: | [Open API](./open-api/index.md) | OpenAPI/Swagger generation. | | [Code Analysis](./code-analysis/index.md) | Analyzers and fixers that catch mistakes at compile time. | -Building the UI on top? Head to the [frontend](../frontend/index.md), which consumes everything here +Building the UI on top? Head to the [frontend](../frontend/index.mdx), which consumes everything here through the generated proxies. diff --git a/Documentation/frontend/react/index.md b/Documentation/frontend/react/index.md index 22a020935..f74431f4f 100644 --- a/Documentation/frontend/react/index.md +++ b/Documentation/frontend/react/index.md @@ -28,7 +28,7 @@ those two with validation, scopes, and identity. | [Identity](./identity.md) | Who the user is, and what they're allowed to see and do. | | [Dialogs](./dialogs.md) | Consistent dialog handling for command and data-entry flows. | | [Proxy Generation](../../backend/proxy-generation/index.md) | How the typed proxies you import here are generated from C#. | -| [Storybook](./storybook.md) | The Storybook for the components Arc exposes. | +| [Storybook](./storybook.mdx) | The Storybook for the components Arc exposes. | | [Story Components](./stories) | Building good-looking, consistent stories. | Prefer a structured, testable approach for complex screens? See [MVVM with React](../react.mvvm/index.md). diff --git a/Documentation/tutorial/books-and-relationships.mdx b/Documentation/tutorial/books-and-relationships.mdx index e02392506..3c644fc02 100644 --- a/Documentation/tutorial/books-and-relationships.mdx +++ b/Documentation/tutorial/books-and-relationships.mdx @@ -144,4 +144,4 @@ The `authorId` is context the form needs to be *valid* from the start, so it goe - A **relationship as a foreign key + a filtered query**: `BooksForAuthor` reads exactly one author's catalog, live, with no join. - A React screen that composes the two: a list of authors, each rendering its own live catalog. -Two features, related, both live. We keep *saying* "it stays live" — next we'll make that concrete and watch a screen update itself the instant the data changes. [Let's make it live →](./real-time) +Two features, related, both live. We keep *saying* "it stays live" — next we'll make that concrete and watch a screen update itself the instant the data changes. [Let's make it live →](./real-time.mdx) diff --git a/Documentation/tutorial/first-slice.mdx b/Documentation/tutorial/first-slice.mdx index 031f818df..2862eb6c3 100644 --- a/Documentation/tutorial/first-slice.mdx +++ b/Documentation/tutorial/first-slice.mdx @@ -255,4 +255,4 @@ In one folder, read top to bottom: That's a complete vertical slice, backend to browser, over a plain database. The next feature will be another folder just like it. -There's one problem, though: right now a librarian can register an author with a blank name, or the same author twice, and nothing stops them. A real app has to say no. [Let's make it trustworthy →](./validation) +There's one problem, though: right now a librarian can register an author with a blank name, or the same author twice, and nothing stops them. A real app has to say no. [Let's make it trustworthy →](./validation.mdx) diff --git a/Documentation/tutorial/index.md b/Documentation/tutorial/index.md index 963882e42..c470c8a18 100644 --- a/Documentation/tutorial/index.md +++ b/Documentation/tutorial/index.md @@ -68,10 +68,10 @@ An Arc project running locally with a database. The [Get started](/arc/backend/g ## The tour -1. **[Your first full-stack slice](./first-slice)** — register an author from C# all the way to a live React screen, fully typed. -2. **[Make it trustworthy](./validation)** — reject bad input with a validator and a uniqueness rule, and show the reason in the form. -3. **[Relate your slices](./books-and-relationships)** — add books that belong to an author, and read them back. -4. **[Make it live](./real-time)** — observable queries that update the screen the moment the data changes. -5. **[Decide who can do what](./authorization)** — lock the catalog down with role-based authorization. +1. **[Your first full-stack slice](./first-slice.mdx)** — register an author from C# all the way to a live React screen, fully typed. +2. **[Make it trustworthy](./validation.mdx)** — reject bad input with a validator and a uniqueness rule, and show the reason in the form. +3. **[Relate your slices](./books-and-relationships.mdx)** — add books that belong to an author, and read them back. +4. **[Make it live](./real-time.mdx)** — observable queries that update the screen the moment the data changes. +5. **[Decide who can do what](./authorization.mdx)** — lock the catalog down with role-based authorization. -Each chapter ends where the next begins. By the end you'll have a real full-stack feature — and the model to build your own. Ready? [Let's build the first slice →](./first-slice) +Each chapter ends where the next begins. By the end you'll have a real full-stack feature — and the model to build your own. Ready? [Let's build the first slice →](./first-slice.mdx) diff --git a/Documentation/tutorial/real-time.mdx b/Documentation/tutorial/real-time.mdx index 480317f6b..2141cb9b5 100644 --- a/Documentation/tutorial/real-time.mdx +++ b/Documentation/tutorial/real-time.mdx @@ -79,4 +79,4 @@ That's the whole switch between "load once" and "stay live." You choose per quer - A clear picture of **why your screens update themselves** — observable queries are standing subscriptions, database to browser. - The one-line difference between a **one-shot** query and a **live** one — the return type, nothing else. -The catalog is live. There's just one thing left before it's a real back office: right now *anyone* can register authors and add books. Let's decide who's allowed. [Lock it down →](./authorization) +The catalog is live. There's just one thing left before it's a real back office: right now *anyone* can register authors and add books. Let's decide who's allowed. [Lock it down →](./authorization.mdx) diff --git a/Documentation/tutorial/validation.mdx b/Documentation/tutorial/validation.mdx index 4da45058e..8663fca71 100644 --- a/Documentation/tutorial/validation.mdx +++ b/Documentation/tutorial/validation.mdx @@ -101,4 +101,4 @@ One rule on the value type, one rule on the command, and both ends of the app ho - A `RegisterAuthorValidator` **business rule** that checks existing state in the database, with a unique index behind it for the hard guarantee. - A React form that surfaces both — client-side and server-side — with no extra frontend code. -Our author is trustworthy now. But an author with no books is a lonely thing. Next we'll give them a catalog — a second feature that *relates* to this one. [Let's add books →](./books-and-relationships) +Our author is trustworthy now. But an author with no books is a lonely thing. Next we'll give them a catalog — a second feature that *relates* to this one. [Let's add books →](./books-and-relationships.mdx) diff --git a/Documentation/verify-links.sh b/Documentation/verify-links.sh new file mode 100755 index 000000000..ad1c9304e --- /dev/null +++ b/Documentation/verify-links.sh @@ -0,0 +1,85 @@ +#!/bin/bash + +# Link Verification Script +# Verifies the links in the documentation of this repository, and is used both +# by verify-markdown.sh and by the Markdown Verification workflow so the two +# cannot drift apart. +# +# What is verified here: +# - relative links between pages in this repository +# - external http(s) links +# +# What is not verified here: +# Links written as site-absolute paths (/arc/..., /chronicle/...) address the +# aggregated documentation site, where each product's Documentation folder is +# mounted under its own prefix. They cannot resolve against a single +# repository and are verified when that site is built. They are skipped by +# rule rather than by name, so a new prefix needs no change here. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Pinned so that a new major version cannot silently change what is scanned. +LINKINATOR_VERSION="8.0.2" + +# linkinator serves the scanned files from a local web server, so every internal +# link resolves to http://127.0.0.1:/. Skipping what falls outside +# /Documentation/ therefore skips exactly the site-absolute paths this +# repository cannot resolve, and nothing else. The pattern must never match the +# crawl root itself - a pattern that did is what turned this check into a no-op. +SITE_ABSOLUTE_LINKS='^https?://(localhost|127\.0\.0\.1):[0-9]+/(?!Documentation/)' + +cd "$ROOT_DIR" + +if ! command -v npx &> /dev/null; then + echo "Error: npx is not installed. Please install Node.js and npm." + exit 1 +fi + +echo "Checking links in Documentation..." +echo "This may take a few minutes to check all links..." +echo "" + +set +e +OUTPUT=$(npx --yes "linkinator@$LINKINATOR_VERSION" \ + "Documentation/**/*.md" \ + "Documentation/**/*.mdx" \ + --markdown \ + --recurse \ + --directory-listing \ + --verbosity error \ + --status-code "403:ok" \ + --skip "$SITE_ABSOLUTE_LINKS" 2>&1) +LINKINATOR_EXIT_CODE=$? +set -e + +echo "$OUTPUT" + +# How many links were actually looked at. A check that scans nothing reports +# success while verifying nothing, which reads exactly like a passing check. +SCANNED=$(echo "$OUTPUT" | grep -oE 'canned [0-9]+ link' | grep -oE '[0-9]+' | tail -1) + +echo "" + +if [ -z "$SCANNED" ]; then + echo "✗ Link verification could not determine how many links were scanned." + echo " linkinator reported no scan summary - the file globs most likely matched nothing." + exit 1 +fi + +if [ "$SCANNED" -eq 0 ]; then + echo "✗ Link verification scanned zero links." + echo " Nothing was checked, so this run proves nothing about the links in Documentation." + exit 1 +fi + +if [ $LINKINATOR_EXIT_CODE -ne 0 ]; then + echo "✗ Link verification failed - $SCANNED links scanned." + exit $LINKINATOR_EXIT_CODE +fi + +echo "✓ Link verification passed - $SCANNED links scanned." +echo " Site-absolute links (/arc/..., /chronicle/...) resolve only on the aggregated" +echo " documentation site and are verified when that site is built." diff --git a/Documentation/verify-markdown.sh b/Documentation/verify-markdown.sh index 9fae3f01d..8bc90ce92 100755 --- a/Documentation/verify-markdown.sh +++ b/Documentation/verify-markdown.sh @@ -13,10 +13,9 @@ echo "Markdown Verification" echo "==========================================" echo "" -# Check if running from repository root or Documentation folder -if [ "$(basename "$PWD")" = "Documentation" ]; then - cd .. -fi +# Both steps address paths from the repository root, whether this script is run +# from there or from the Documentation folder. +cd "$ROOT_DIR" echo "Working directory: $PWD" echo "" @@ -32,8 +31,13 @@ if ! command -v npx &> /dev/null; then exit 1 fi -npx markdownlint-cli2 "Documentation/**/*.md" -LINT_EXIT_CODE=$? +# Each step captures its own exit code and the run continues, so that a failing +# lint step does not leave the state of the links unreported. +if npx --yes markdownlint-cli2 "Documentation/**/*.md"; then + LINT_EXIT_CODE=0 +else + LINT_EXIT_CODE=$? +fi echo "" if [ $LINT_EXIT_CODE -eq 0 ]; then @@ -48,17 +52,11 @@ echo "==========================================" echo "Step 2: Running link verification..." echo "==========================================" echo "" -echo "This may take a few minutes to check all links..." -echo "" - -npx linkinator "Documentation/**/*.md" --markdown --recurse --verbosity error --status-code "403:ok" --skip "^(https?:\\/\\/)?(localhost|127\\.0\\.0\\.1)(:\\d+)?(\\/|$)" -LINK_EXIT_CODE=$? -echo "" -if [ $LINK_EXIT_CODE -eq 0 ]; then - echo "✓ Link verification passed!" +if "$SCRIPT_DIR/verify-links.sh"; then + LINK_EXIT_CODE=0 else - echo "✗ Link verification failed with exit code $LINK_EXIT_CODE" + LINK_EXIT_CODE=$? fi echo ""